From f600083deec98997f3fa66d030c8bb634800cf30 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 16:14:06 +0200
Subject: [PATCH 001/493] initial webhook view #2036

---
 InvenTree/InvenTree/urls.py                   |   4 +-
 InvenTree/common/admin.py                     |   8 +-
 InvenTree/common/api.py                       | 117 ++++++++++++++++++
 .../common/migrations/0012_webhookendpoint.py |  28 +++++
 InvenTree/common/models.py                    |  50 ++++++++
 5 files changed, 205 insertions(+), 2 deletions(-)
 create mode 100644 InvenTree/common/migrations/0012_webhookendpoint.py

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 7d51c6a4cf..b586bd6a65 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -54,7 +54,6 @@ admin.site.site_header = "InvenTree Admin"
 
 apipatterns = [
     url(r'^barcode/', include(barcode_api_urls)),
-    url(r'^common/', include(common_api_urls)),
     url(r'^part/', include(part_api_urls)),
     url(r'^bom/', include(bom_api_urls)),
     url(r'^company/', include(company_api_urls)),
@@ -70,6 +69,9 @@ apipatterns = [
     # Plugin endpoints
     url(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
 
+    # Webhook enpoint
+    path('', include(common_api_urls)),
+
     # InvenTree information endpoint
     url(r'^$', InfoView.as_view(), name='api-inventree-info'),
 
diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py
index 1eda18e869..50954924ff 100644
--- a/InvenTree/common/admin.py
+++ b/InvenTree/common/admin.py
@@ -5,7 +5,7 @@ from django.contrib import admin
 
 from import_export.admin import ImportExportModelAdmin
 
-from .models import InvenTreeSetting, InvenTreeUserSetting
+from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint
 
 
 class SettingsAdmin(ImportExportModelAdmin):
@@ -18,5 +18,11 @@ class UserSettingsAdmin(ImportExportModelAdmin):
     list_display = ('key', 'value', 'user', )
 
 
+class WebhookAdmin(ImportExportModelAdmin):
+
+    list_display = ('endpoint_id', 'name', 'active', 'user')
+
+
 admin.site.register(InvenTreeSetting, SettingsAdmin)
 admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
+admin.site.register(WebhookEndpoint, WebhookAdmin)
diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index 8a2dfbd6a7..efde6e863e 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -5,5 +5,122 @@ Provides a JSON API for common components.
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import json
+from secrets import compare_digest
+
+from django.utils.decorators import method_decorator
+from django.urls import path
+from django.views.decorators.csrf import csrf_exempt
+
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.exceptions import PermissionDenied, NotFound, NotAcceptable
+
+from .models import WebhookEndpoint
+
+
+class CsrfExemptMixin(object):
+    """
+    Exempts the view from CSRF requirements.
+    """
+
+    @method_decorator(csrf_exempt)
+    def dispatch(self, *args, **kwargs):
+        return super(CsrfExemptMixin, self).dispatch(*args, **kwargs)
+
+
+class VerificationMethod:
+    NONE = 0
+    TOKEN = 1
+    HMAC = 2
+
+
+class WebhookView(CsrfExemptMixin, APIView):
+    """
+    Endpoint for receiving webhoks.
+    """
+    authentication_classes = []
+    permission_classes = []
+    model_class = WebhookEndpoint
+
+    # Token
+    TOKEN_NAME = "Token"
+    VERIFICATION_METHOD = VerificationMethod.NONE
+
+    MESSAGE_OK = "Message was received."
+    MESSAGE_TOKEN_ERROR = "Incorrect token in header."
+
+    def post(self, request, endpoint, *args, **kwargs):
+        self.init(request, *args, **kwargs)
+        # get webhook definition
+        self.get_webhook(endpoint, *args, **kwargs)
+        # check headers
+        headers = request.headers
+        self.validate_token(headers)
+
+        # process data
+        try:
+            payload = json.loads(request.body)
+        except json.decoder.JSONDecodeError as error:
+            raise NotAcceptable(error.msg)
+        self.save_data(payload, headers, request)
+        self.process_payload(payload, headers, request)
+
+        # return results
+        return_kwargs = self.get_result(payload, headers, request)
+        return Response(**return_kwargs)
+
+    # To be overridden
+    def init(self, request, *args, **kwargs):
+        self.token = ''
+        self.verify = self.VERIFICATION_METHOD
+
+    def get_webhook(self, endpoint):
+        try:
+            webhook = self.model_class.objects.get(endpoint_id=endpoint)
+            self.webhook = webhook
+            return self.process_webhook()
+        except self.model_class.DoesNotExist:
+            raise NotFound()
+
+    def process_webhook(self):
+        if self.webhook.token:
+            self.token = self.webhook.token
+            self.verify = VerificationMethod.TOKEN
+        return True
+
+    def validate_token(self, headers):
+        token = headers.get(self.TOKEN_NAME, "")
+
+        # no token
+        if self.verify == VerificationMethod.NONE:
+            return True
+
+        # static token
+        elif self.verify == VerificationMethod.TOKEN:
+            if not compare_digest(token, self.token):
+                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
+            return True
+
+        # hmac token
+        elif self.verify == VerificationMethod.HMAC:
+            # TODO write check
+            return True
+
+    def save_data(self, payload, headers=None, request=None):
+        # TODO safe data
+        return
+
+    def process_payload(self, payload, headers=None, request=None):
+        return
+
+    def get_result(self, payload, headers=None, request=None):
+        context = {}
+        context['data'] = {'message': self.MESSAGE_OK}
+        context['status'] = 200
+        return context
+
+
 common_api_urls = [
+    path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
 ]
diff --git a/InvenTree/common/migrations/0012_webhookendpoint.py b/InvenTree/common/migrations/0012_webhookendpoint.py
new file mode 100644
index 0000000000..b3cf6bce2f
--- /dev/null
+++ b/InvenTree/common/migrations/0012_webhookendpoint.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.4 on 2021-09-12 12:42
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('common', '0011_auto_20210722_2114'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='WebhookEndpoint',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('endpoint_id', models.CharField(default=uuid.uuid4, editable=False, help_text='Endpoint at which this webhook is received', max_length=255, verbose_name='Endpoint')),
+                ('name', models.CharField(blank=True, help_text='Name for this webhook', max_length=255, null=True, verbose_name='Name')),
+                ('active', models.BooleanField(default=True, help_text='Is this webhook active', verbose_name='Active')),
+                ('token', models.CharField(blank=True, default=uuid.uuid4, help_text='Token for access', max_length=255, null=True, verbose_name='Token')),
+                ('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
+            ],
+        ),
+    ]
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index aed6f2bf14..df64cd5cbc 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -9,6 +9,7 @@ from __future__ import unicode_literals
 import os
 import decimal
 import math
+import uuid
 
 from django.db import models, transaction
 from django.contrib.auth.models import User
@@ -1165,3 +1166,52 @@ class ColorTheme(models.Model):
                 return True
 
         return False
+
+
+class WebhookEndpoint(models.Model):
+    """ Defines a Webhook entdpoint
+
+    Attributes:
+        endpoint_id: Path to the webhook,
+        name: Name of the webhook,
+        active: Is this webhook active?,
+        user: User associated with webhook,
+        token: Token for sending a webhook,
+    """
+
+    endpoint_id = models.CharField(
+        max_length=255,
+        verbose_name=_('Endpoint'),
+        help_text=_('Endpoint at which this webhook is received'),
+        default=uuid.uuid4,
+        editable=False,
+    )
+
+    name = models.CharField(
+        max_length=255,
+        blank=True, null=True,
+        verbose_name=_('Name'),
+        help_text=_('Name for this webhook')
+    )
+
+    active = models.BooleanField(
+        default=True,
+        verbose_name=_('Active'),
+        help_text=_('Is this webhook active')
+    )
+
+    user = models.ForeignKey(
+        User,
+        on_delete=models.SET_NULL,
+        blank=True, null=True,
+        verbose_name=_('User'),
+        help_text=_('User'),
+    )
+
+    token = models.CharField(
+        max_length=255,
+        blank=True, null=True,
+        verbose_name=_('Token'),
+        help_text=_('Token for access'),
+        default=uuid.uuid4,
+    )

From 5bf9561984a3af80b9c41f447c987a7d0379589a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 16:32:40 +0200
Subject: [PATCH 002/493] refactor

---
 InvenTree/common/api.py | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index efde6e863e..ea363fa394 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -54,15 +54,17 @@ class WebhookView(CsrfExemptMixin, APIView):
         self.init(request, *args, **kwargs)
         # get webhook definition
         self.get_webhook(endpoint, *args, **kwargs)
+
         # check headers
         headers = request.headers
-        self.validate_token(headers)
-
-        # process data
         try:
             payload = json.loads(request.body)
         except json.decoder.JSONDecodeError as error:
             raise NotAcceptable(error.msg)
+
+        # validate
+        self.validate_token(payload, headers)
+        # process data
         self.save_data(payload, headers, request)
         self.process_payload(payload, headers, request)
 
@@ -87,25 +89,25 @@ class WebhookView(CsrfExemptMixin, APIView):
         if self.webhook.token:
             self.token = self.webhook.token
             self.verify = VerificationMethod.TOKEN
+            # TODO make a object-setting
         return True
 
-    def validate_token(self, headers):
+    def validate_token(self, payload, headers):
         token = headers.get(self.TOKEN_NAME, "")
 
         # no token
         if self.verify == VerificationMethod.NONE:
-            return True
+            pass
 
         # static token
         elif self.verify == VerificationMethod.TOKEN:
             if not compare_digest(token, self.token):
                 raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
-            return True
 
         # hmac token
         elif self.verify == VerificationMethod.HMAC:
-            # TODO write check
-            return True
+
+        return True
 
     def save_data(self, payload, headers=None, request=None):
         # TODO safe data

From 68ca6729376f56fff2ef59fa541242e3fffe3550 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 16:49:47 +0200
Subject: [PATCH 003/493] hmac verification

---
 InvenTree/common/api.py                        | 12 ++++++++++++
 .../migrations/0013_auto_20210912_1443.py      | 18 ++++++++++++++++++
 InvenTree/common/models.py                     |  8 ++++++++
 3 files changed, 38 insertions(+)
 create mode 100644 InvenTree/common/migrations/0013_auto_20210912_1443.py

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index ea363fa394..e307d5485f 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -6,6 +6,9 @@ Provides a JSON API for common components.
 from __future__ import unicode_literals
 
 import json
+import hmac
+import hashlib
+import base64
 from secrets import compare_digest
 
 from django.utils.decorators import method_decorator
@@ -75,6 +78,7 @@ class WebhookView(CsrfExemptMixin, APIView):
     # To be overridden
     def init(self, request, *args, **kwargs):
         self.token = ''
+        self.secret = ''
         self.verify = self.VERIFICATION_METHOD
 
     def get_webhook(self, endpoint):
@@ -90,6 +94,10 @@ class WebhookView(CsrfExemptMixin, APIView):
             self.token = self.webhook.token
             self.verify = VerificationMethod.TOKEN
             # TODO make a object-setting
+        if self.webhook.secret:
+            self.secret = self.webhook.secret
+            self.verify = VerificationMethod.HMAC
+            # TODO make a object-setting
         return True
 
     def validate_token(self, payload, headers):
@@ -106,6 +114,10 @@ class WebhookView(CsrfExemptMixin, APIView):
 
         # hmac token
         elif self.verify == VerificationMethod.HMAC:
+            digest = hmac.new(self.secret, payload.encode('utf-8'), hashlib.sha256).digest()
+            computed_hmac = base64.b64encode(digest)
+            if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
+                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
 
         return True
 
diff --git a/InvenTree/common/migrations/0013_auto_20210912_1443.py b/InvenTree/common/migrations/0013_auto_20210912_1443.py
new file mode 100644
index 0000000000..f9c05fe05f
--- /dev/null
+++ b/InvenTree/common/migrations/0013_auto_20210912_1443.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-09-12 14:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0012_webhookendpoint'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='webhookendpoint',
+            name='secret',
+            field=models.CharField(blank=True, help_text='Shared secret for HMAC', max_length=255, null=True, verbose_name='Secret'),
+        ),
+    ]
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index df64cd5cbc..ca13e84357 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1177,6 +1177,7 @@ class WebhookEndpoint(models.Model):
         active: Is this webhook active?,
         user: User associated with webhook,
         token: Token for sending a webhook,
+        secret: Shared secret for HMAC verification,
     """
 
     endpoint_id = models.CharField(
@@ -1215,3 +1216,10 @@ class WebhookEndpoint(models.Model):
         help_text=_('Token for access'),
         default=uuid.uuid4,
     )
+
+    secret = models.CharField(
+        max_length=255,
+        blank=True, null=True,
+        verbose_name=_('Secret'),
+        help_text=_('Shared secret for HMAC'),
+    )

From 440311cddb4ab0b3cbb9ce44776c3008a7b67b47 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 17:36:57 +0200
Subject: [PATCH 004/493] ruleset

---
 InvenTree/users/models.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 179a70ed74..3ebaefe5a4 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -142,6 +142,7 @@ class RuleSet(models.Model):
         'common_colortheme',
         'common_inventreesetting',
         'common_inventreeusersetting',
+        'common_webhookendpoint',
         'company_contact',
         'users_owner',
 

From e2bb5e978bb962c5a41498b79879680fd4f961db Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 19:25:36 +0200
Subject: [PATCH 005/493] fix hmac

---
 InvenTree/common/api.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index e307d5485f..a0d69bd2ab 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -66,7 +66,7 @@ class WebhookView(CsrfExemptMixin, APIView):
             raise NotAcceptable(error.msg)
 
         # validate
-        self.validate_token(payload, headers)
+        self.validate_token(payload, headers, request)
         # process data
         self.save_data(payload, headers, request)
         self.process_payload(payload, headers, request)
@@ -100,7 +100,7 @@ class WebhookView(CsrfExemptMixin, APIView):
             # TODO make a object-setting
         return True
 
-    def validate_token(self, payload, headers):
+    def validate_token(self, payload, headers, request):
         token = headers.get(self.TOKEN_NAME, "")
 
         # no token
@@ -114,7 +114,7 @@ class WebhookView(CsrfExemptMixin, APIView):
 
         # hmac token
         elif self.verify == VerificationMethod.HMAC:
-            digest = hmac.new(self.secret, payload.encode('utf-8'), hashlib.sha256).digest()
+            digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest()
             computed_hmac = base64.b64encode(digest)
             if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
                 raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)

From 0e2db232ae4441f575b11762e506dc5f33d712b9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 22:24:25 +0200
Subject: [PATCH 006/493] save messages

---
 InvenTree/common/admin.py                     |  3 +-
 InvenTree/common/api.py                       | 18 +++---
 .../migrations/0014_auto_20210912_1804.py     | 26 +++++++++
 InvenTree/common/models.py                    | 57 +++++++++++++++++++
 4 files changed, 96 insertions(+), 8 deletions(-)
 create mode 100644 InvenTree/common/migrations/0014_auto_20210912_1804.py

diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py
index 50954924ff..088f53dc39 100644
--- a/InvenTree/common/admin.py
+++ b/InvenTree/common/admin.py
@@ -5,7 +5,7 @@ from django.contrib import admin
 
 from import_export.admin import ImportExportModelAdmin
 
-from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint
+from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage
 
 
 class SettingsAdmin(ImportExportModelAdmin):
@@ -26,3 +26,4 @@ class WebhookAdmin(ImportExportModelAdmin):
 admin.site.register(InvenTreeSetting, SettingsAdmin)
 admin.site.register(InvenTreeUserSetting, UserSettingsAdmin)
 admin.site.register(WebhookEndpoint, WebhookAdmin)
+admin.site.register(WebhookMessage, ImportExportModelAdmin)
diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index a0d69bd2ab..bfd7163710 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -19,7 +19,7 @@ from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.exceptions import PermissionDenied, NotFound, NotAcceptable
 
-from .models import WebhookEndpoint
+from .models import WebhookEndpoint, WebhookMessage
 
 
 class CsrfExemptMixin(object):
@@ -68,8 +68,8 @@ class WebhookView(CsrfExemptMixin, APIView):
         # validate
         self.validate_token(payload, headers, request)
         # process data
-        self.save_data(payload, headers, request)
-        self.process_payload(payload, headers, request)
+        message = self.save_data(payload, headers, request)
+        self.process_payload(message, payload, headers)
 
         # return results
         return_kwargs = self.get_result(payload, headers, request)
@@ -122,11 +122,15 @@ class WebhookView(CsrfExemptMixin, APIView):
         return True
 
     def save_data(self, payload, headers=None, request=None):
-        # TODO safe data
-        return
+        return WebhookMessage.objects.create(
+            host=request.host,
+            header=headers,
+            body=payload,
+            endpoint=self.webhook,
+        )
 
-    def process_payload(self, payload, headers=None, request=None):
-        return
+    def process_payload(self, message, payload=None, headers=None):
+        return True
 
     def get_result(self, payload, headers=None, request=None):
         context = {}
diff --git a/InvenTree/common/migrations/0014_auto_20210912_1804.py b/InvenTree/common/migrations/0014_auto_20210912_1804.py
new file mode 100644
index 0000000000..18feb0d4a4
--- /dev/null
+++ b/InvenTree/common/migrations/0014_auto_20210912_1804.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.4 on 2021-09-12 18:04
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0013_auto_20210912_1443'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='WebhookMessage',
+            fields=[
+                ('message_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this message', primary_key=True, serialize=False, verbose_name='Message ID')),
+                ('host', models.CharField(editable=False, help_text='Host from which this message was received', max_length=255, verbose_name='Host')),
+                ('header', models.CharField(blank=True, editable=False, help_text='Header of this message', max_length=255, null=True, verbose_name='Header')),
+                ('body', models.JSONField(blank=True, editable=False, help_text='Body of this message', null=True, verbose_name='Body')),
+                ('worked_on', models.BooleanField(default=False, help_text='Was the work on this message finished?', verbose_name='Worked on')),
+                ('endpoint', models.ForeignKey(blank=True, help_text='Endpoint on which this message was received', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.webhookendpoint', verbose_name='Endpoint')),
+            ],
+        ),
+    ]
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index ca13e84357..4bd24e878e 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1223,3 +1223,60 @@ class WebhookEndpoint(models.Model):
         verbose_name=_('Secret'),
         help_text=_('Shared secret for HMAC'),
     )
+
+
+class WebhookMessage(models.Model):
+    """ Defines a webhook message
+
+    Attributes:
+        message_id: Unique identifier for this message,
+        host: Host from which this message was received,
+        header: Header of this message,
+        body: Body of this message,
+        endpoint: Endpoint on which this message was received,
+        worked_on: Was the work on this message finished?
+    """
+
+    message_id = models.UUIDField(
+        verbose_name=_('Message ID'),
+        help_text=_('Unique identifier for this message'),
+        primary_key=True,
+        default=uuid.uuid4,
+        editable=False,
+    )
+
+    host = models.CharField(
+        max_length=255,
+        verbose_name=_('Host'),
+        help_text=_('Host from which this message was received'),
+        editable=False,
+    )
+
+    header = models.CharField(
+        max_length=255,
+        blank=True, null=True,
+        verbose_name=_('Header'),
+        help_text=_('Header of this message'),
+        editable=False,
+    )
+
+    body = models.JSONField(
+        blank=True, null=True,
+        verbose_name=_('Body'),
+        help_text=_('Body of this message'),
+        editable=False,
+    )
+
+    endpoint = models.ForeignKey(
+        WebhookEndpoint,
+        on_delete=models.SET_NULL,
+        blank=True, null=True,
+        verbose_name=_('Endpoint'),
+        help_text=_('Endpoint on which this message was received'),
+    )
+
+    worked_on = models.BooleanField(
+        default=False,
+        verbose_name=_('Worked on'),
+        help_text=_('Was the work on this message finished?'),
+    )

From 4baf714c8598a0253b4677ac30eb940ae6597598 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 22:26:20 +0200
Subject: [PATCH 007/493] let tasks run async

---
 InvenTree/common/api.py | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index bfd7163710..68a27b9475 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -18,6 +18,7 @@ from django.views.decorators.csrf import csrf_exempt
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.exceptions import PermissionDenied, NotFound, NotAcceptable
+from django_q.tasks import async_task
 
 from .models import WebhookEndpoint, WebhookMessage
 
@@ -45,6 +46,7 @@ class WebhookView(CsrfExemptMixin, APIView):
     authentication_classes = []
     permission_classes = []
     model_class = WebhookEndpoint
+    run_async = False
 
     # Token
     TOKEN_NAME = "Token"
@@ -69,12 +71,22 @@ class WebhookView(CsrfExemptMixin, APIView):
         self.validate_token(payload, headers, request)
         # process data
         message = self.save_data(payload, headers, request)
-        self.process_payload(message, payload, headers)
+        if self.run_async:
+            async_task(self._process_payload, message.id)
+        else:
+            message.worked_on = self.process_payload(message, payload, headers)
+            message.save()
 
         # return results
         return_kwargs = self.get_result(payload, headers, request)
         return Response(**return_kwargs)
 
+    def _process_payload(self, message_id):
+        message = WebhookMessage.objects.get(message_id=message_id)
+        process_result = self.process_payload(message, message.body, message.header)
+        message.worked_on = process_result
+        message.save()
+
     # To be overridden
     def init(self, request, *args, **kwargs):
         self.token = ''

From ca3e9709e18eb0f80aba43b67ee1698ab3dbd376 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 12 Sep 2021 22:46:41 +0200
Subject: [PATCH 008/493] rueset for messafe

---
 InvenTree/users/models.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 3ebaefe5a4..031933e590 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -143,6 +143,7 @@ class RuleSet(models.Model):
         'common_inventreesetting',
         'common_inventreeusersetting',
         'common_webhookendpoint',
+        'common_webhookmessage',
         'company_contact',
         'users_owner',
 

From e73bf7be23af07a2cc4c467ffd2bc4c9d34e7557 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 13 Sep 2021 00:16:41 +0200
Subject: [PATCH 009/493] tests for base functions

---
 InvenTree/common/api.py   |  3 ++-
 InvenTree/common/tests.py | 55 +++++++++++++++++++++++++++++++++++++--
 2 files changed, 55 insertions(+), 3 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index 68a27b9475..16b4199a0b 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -135,7 +135,8 @@ class WebhookView(CsrfExemptMixin, APIView):
 
     def save_data(self, payload, headers=None, request=None):
         return WebhookMessage.objects.create(
-            host=request.host,
+            # host=request.host,
+            # TODO fix
             header=headers,
             body=payload,
             endpoint=self.webhook,
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index d20f76baa0..30fcd9cb41 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -1,10 +1,13 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
+from http import HTTPStatus
+import json
 
-from django.test import TestCase
+from django.test import TestCase, Client
 from django.contrib.auth import get_user_model
 
-from .models import InvenTreeSetting
+from .models import InvenTreeSetting, WebhookEndpoint, WebhookMessage
+from .api import WebhookView
 
 
 class SettingsTest(TestCase):
@@ -85,3 +88,51 @@ class SettingsTest(TestCase):
 
                 if setting.default_value not in [True, False]:
                     raise ValueError(f'Non-boolean default value specified for {key}')
+
+
+class WebhookMessageTests(TestCase):
+    def setUp(self):
+        self.endpoint_def = WebhookEndpoint.objects.create()
+        self.url = f'/api/webhook/{self.endpoint_def.endpoint_id}/'
+        self.client = Client(enforce_csrf_checks=True)
+
+    def test_bad_method(self):
+        response = self.client.get(self.url)
+
+        assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
+
+    def test_missing_token(self):
+        response = self.client.post(
+            self.url,
+            content_type='application/json',
+        )
+
+        assert response.status_code == HTTPStatus.FORBIDDEN
+        assert (
+            json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR
+        )
+
+    def test_bad_token(self):
+        response = self.client.post(
+            self.url,
+            content_type='application/json',
+            **{'HTTP_TOKEN': '1234567fghj'},
+        )
+
+        assert response.status_code == HTTPStatus.FORBIDDEN
+        assert (
+            json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR
+        )
+
+    def test_success(self):
+        response = self.client.post(
+            self.url,
+            data={"this": "is a message"},
+            content_type='application/json',
+            **{'HTTP_TOKEN': str(self.endpoint_def.token)},
+        )
+
+        assert response.status_code == HTTPStatus.OK
+        assert json.loads(response.content)['message'] == WebhookView.MESSAGE_OK
+        message = WebhookMessage.objects.get()
+        assert message.body == {"this": "is a message"}

From 4736c3187c3f6ce3e8b27f6f783c467233d9268e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 13 Sep 2021 00:55:21 +0200
Subject: [PATCH 010/493] more coverage

---
 InvenTree/common/tests.py | 68 +++++++++++++++++++++++++++++++++++++--
 1 file changed, 66 insertions(+), 2 deletions(-)

diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 30fcd9cb41..dbc7fc6ed1 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -120,10 +120,74 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.FORBIDDEN
-        assert (
-            json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR
+        assert (json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR)
+
+    def test_bad_url(self):
+        response = self.client.post(
+            f'/api/webhook/1234/',
+            content_type='application/json',
         )
 
+        assert response.status_code == HTTPStatus.NOT_FOUND
+
+    def test_bad_json(self):
+        response = self.client.post(
+            self.url,
+            data="{'this': 123}",
+            content_type='application/json',
+            **{'HTTP_TOKEN': str(self.endpoint_def.token)},
+        )
+
+        assert response.status_code == HTTPStatus.NOT_ACCEPTABLE
+        assert (
+            json.loads(response.content)['detail'] == 'Expecting property name enclosed in double quotes'
+        )
+
+    def test_success_no_token_check(self):
+        # delete token
+        self.endpoint_def.token = ''
+        self.endpoint_def.save()
+
+        # check
+        response = self.client.post(
+            self.url,
+            content_type='application/json',
+        )
+
+        assert response.status_code == HTTPStatus.OK
+        assert json.loads(response.content)['message'] == WebhookView.MESSAGE_OK
+
+    def test_bad_hmac(self):
+        # delete token
+        self.endpoint_def.token = ''
+        self.endpoint_def.secret = '123abc'
+        self.endpoint_def.save()
+
+        # check
+        response = self.client.post(
+            self.url,
+            content_type='application/json',
+        )
+
+        assert response.status_code == HTTPStatus.FORBIDDEN
+        assert (json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR)
+
+    def test_success_hmac(self):
+        # delete token
+        self.endpoint_def.token = ''
+        self.endpoint_def.secret = '123abc'
+        self.endpoint_def.save()
+
+        # check
+        response = self.client.post(
+            self.url,
+            content_type='application/json',
+            **{'HTTP_TOKEN': str('68MXtc/OiXdA5e2Nq9hATEVrZFpLb3Zb0oau7n8s31I=')},
+        )
+
+        assert response.status_code == HTTPStatus.OK
+        assert json.loads(response.content)['message'] == WebhookView.MESSAGE_OK
+
     def test_success(self):
         response = self.client.post(
             self.url,

From 0d6828f4a859199fa664dad1f94375f4db7566b5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 13 Sep 2021 00:57:49 +0200
Subject: [PATCH 011/493] PEP fix

---
 InvenTree/common/tests.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index dbc7fc6ed1..8121b46a6a 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -124,7 +124,7 @@ class WebhookMessageTests(TestCase):
 
     def test_bad_url(self):
         response = self.client.post(
-            f'/api/webhook/1234/',
+            '/api/webhook/1234/',
             content_type='application/json',
         )
 

From 76c28e60d372007ac46d7f1c8e36f6d1409cea56 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 13 Sep 2021 20:50:26 +0200
Subject: [PATCH 012/493] fixing typo

---
 InvenTree/common/api.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index 16b4199a0b..f2479b4a2a 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -41,7 +41,7 @@ class VerificationMethod:
 
 class WebhookView(CsrfExemptMixin, APIView):
     """
-    Endpoint for receiving webhoks.
+    Endpoint for receiving webhooks.
     """
     authentication_classes = []
     permission_classes = []

From 7319150e7c55ef758a78d29390fafd4e413e743c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 15 Sep 2021 07:26:30 +0200
Subject: [PATCH 013/493] refactor of load_plugin

---
 InvenTree/plugins/plugins.py | 23 +++++++++++++++++------
 1 file changed, 17 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index f6b68112bc..8ebfcd7586 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -51,19 +51,30 @@ def get_plugins(pkg, baseclass):
     return plugins
 
 
-def load_action_plugins():
-    """
-    Return a list of all registered action plugins
+def load_plugins(name:str, module, cls):
+    """general function to load a plugin class
+
+    :param name: name of the plugin for logs
+    :type name: str
+    :param module: module from which the plugins should be loaded
+    :return: class of the to-be-loaded plugin
     """
 
-    logger.debug("Loading action plugins")
+    logger.debug(f"Loading {name} plugins")
 
-    plugins = get_plugins(action, ActionPlugin)
+    plugins = get_plugins(module, cls)
 
     if len(plugins) > 0:
-        logger.info("Discovered {n} action plugins:".format(n=len(plugins)))
+        logger.info(f"Discovered {len(plugins)} {name} plugins:")
 
         for ap in plugins:
             logger.debug(" - {ap}".format(ap=ap.PLUGIN_NAME))
 
     return plugins
+
+
+def load_action_plugins():
+    """
+    Return a list of all registered action plugins
+    """
+    return load_plugins('action', action, ActionPlugin)

From 9f3862ab2720d204c55cdc603a5e6cf798a949aa Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 15 Sep 2021 07:40:19 +0200
Subject: [PATCH 014/493] basic integration plugin

---
 InvenTree/plugins/integration/integration.py | 37 ++++++++++++++++++++
 1 file changed, 37 insertions(+)
 create mode 100644 InvenTree/plugins/integration/integration.py

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
new file mode 100644
index 0000000000..fe6d96744d
--- /dev/null
+++ b/InvenTree/plugins/integration/integration.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+
+import logging
+
+import plugins.plugin as plugin
+
+
+logger = logging.getLogger("inventree")
+
+
+class IntegrationPlugin(plugin.InvenTreePlugin):
+    """
+    The IntegrationPlugin class is used to integrate with 3rd party software
+    """
+
+    def __init__(self):
+        """
+        """
+        plugin.InvenTreePlugin.__init__(self)
+
+        self.urls = self.setup_urls()
+
+    def setup_urls(self):
+        """
+        setup url endpoints for this plugin
+        """
+        if self.urlpatterns:
+            return self.urlpatterns
+        return None
+
+    @property
+    def has_urls(self):
+        """
+        does this plugin use custom urls
+        """
+        return bool(self.urls)
+

From d5f022f2cb580cf2a101d4d04b33d0b3540f98c6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 15 Sep 2021 07:53:19 +0200
Subject: [PATCH 015/493] url integration

---
 InvenTree/InvenTree/urls.py                  | 14 ++++++++++++++
 InvenTree/plugins/integration/integration.py | 10 ++++++++++
 InvenTree/plugins/plugins.py                 | 10 ++++++++++
 3 files changed, 34 insertions(+)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index b586bd6a65..11fa976869 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -48,6 +48,8 @@ from common.views import SettingEdit, UserSettingEdit
 from .api import InfoView, NotFoundView
 from .api import ActionPluginView
 
+from plugins import plugins as inventree_plugins
+
 from users.api import user_urls
 
 admin.site.site_header = "InvenTree Admin"
@@ -125,6 +127,15 @@ translated_javascript_urls = [
     url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
 ]
 
+# Integration plugin urls
+integration_plugins = inventree_plugins.load_integration_plugins()
+interation_urls = []
+for plugin in integration_plugins:
+    # initialize
+    plugin = plugin()
+    if plugin.has_urls:
+        interation_urls.append(plugin.urlpatterns)
+
 urlpatterns = [
     url(r'^part/', include(part_urls)),
     url(r'^manufacturer-part/', include(manufacturer_part_urls)),
@@ -167,6 +178,9 @@ urlpatterns = [
     url(r'^api/', include(apipatterns)),
     url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
 
+    # plugins
+    url(r'^plugin/', include(interation_urls)),
+
     url(r'^markdownx/', include('markdownx.urls')),
 ]
 
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index fe6d96744d..d4ae0d302a 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -2,6 +2,7 @@
 
 import logging
 
+from django.conf.urls import url, include
 import plugins.plugin as plugin
 
 
@@ -28,6 +29,15 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
             return self.urlpatterns
         return None
 
+    @property
+    def urlpatterns(self):
+        """
+        retruns the urlpatterns for this plugin
+        """
+        if self.has_urls:
+            return url(f'^{self.plugin_name()}/', include(self.urls), name=self.plugin_name())
+        return None
+
     @property
     def has_urls(self):
         """
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 8ebfcd7586..15b5a94c51 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -9,6 +9,9 @@ import logging
 import plugins.action as action
 from plugins.action.action import ActionPlugin
 
+import plugins.integration as integration
+from plugins.integration.integration import IntegrationPlugin
+
 
 logger = logging.getLogger("inventree")
 
@@ -78,3 +81,10 @@ def load_action_plugins():
     Return a list of all registered action plugins
     """
     return load_plugins('action', action, ActionPlugin)
+
+
+def load_integration_plugins():
+    """
+    Return a list of all registered integration plugins
+    """
+    return load_plugins('integration', integration, IntegrationPlugin)

From 682ee87267f637b21ffd8e3ffc2df447aa52c3b4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 17 Sep 2021 22:44:11 +0200
Subject: [PATCH 016/493] settings per plugin

---
 InvenTree/InvenTree/settings.py              | 16 +++++++++++++++
 InvenTree/common/models.py                   |  2 ++
 InvenTree/plugins/integration/integration.py | 21 ++++++++++++++++++++
 3 files changed, 39 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index f3c166df88..b1bf4ab05f 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -26,6 +26,8 @@ import yaml
 from django.utils.translation import gettext_lazy as _
 from django.contrib.messages import constants as messages
 
+from plugins import plugins as inventree_plugins
+
 
 def _is_true(x):
     # Shortcut function to determine if a value "looks" like a boolean
@@ -646,3 +648,17 @@ MESSAGE_TAGS = {
     messages.ERROR: 'alert alert-block alert-danger',
     messages.INFO: 'alert alert-block alert-info',
 }
+
+# Plugins
+INTEGRATION_PLUGINS = inventree_plugins.load_integration_plugins()
+
+INTEGRATION_PLUGIN_SETTINGS = {}
+INTEGRATION_PLUGIN_SETTING = {}
+INTEGRATION_PLUGIN_LIST = {}
+
+for plugin in INTEGRATION_PLUGINS:
+    plugin = plugin()
+    if plugin.has_settings:
+        INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
+        INTEGRATION_PLUGIN_SETTING[plugin.plugin_name()] = plugin.settingspatterns
+        INTEGRATION_PLUGIN_SETTINGS.update(plugin.settingspatterns)
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 4bd24e878e..700463849c 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -827,6 +827,8 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'default': True,
             'validator': bool,
         },
+
+        **settings.INTEGRATION_PLUGIN_SETTINGS
     }
 
     class Meta:
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index d4ae0d302a..81d64393b3 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -20,6 +20,7 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         plugin.InvenTreePlugin.__init__(self)
 
         self.urls = self.setup_urls()
+        self.settings = self.setup_settings()
 
     def setup_urls(self):
         """
@@ -45,3 +46,23 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         """
         return bool(self.urls)
 
+    def setup_settings(self):
+        """
+        setup settings for this plugin
+        """
+        if self.SETTINGS:
+            return self.SETTINGS
+        return None
+
+    @property
+    def has_settings(self):
+        """
+        does this plugin use custom settings
+        """
+        return bool(self.settings)
+
+    @property
+    def settingspatterns(self):
+        if self.has_settings:
+            return {f'PLUGIN_{self.plugin_name().upper()}_{key}': value for key, value in self.settings.items()}
+        return None

From 771c453c404dfe189d312df3a99568d75b4d3f00 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 17 Sep 2021 22:47:49 +0200
Subject: [PATCH 017/493] settings UI integration

---
 InvenTree/part/templatetags/plugin_extras.py  | 22 ++++++++++++++++
 .../templates/InvenTree/settings/navbar.html  | 17 +++++++++++++
 .../InvenTree/settings/plugin_settings.html   | 25 +++++++++++++++++++
 .../InvenTree/settings/settings.html          |  8 ++++++
 4 files changed, 72 insertions(+)
 create mode 100644 InvenTree/part/templatetags/plugin_extras.py
 create mode 100644 InvenTree/templates/InvenTree/settings/plugin_settings.html

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
new file mode 100644
index 0000000000..af80c46ccb
--- /dev/null
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+
+""" This module provides template tags for handeling plugins
+"""
+from django.utils.translation import ugettext_lazy as _
+from django.conf import settings as djangosettings
+
+from django import template
+
+
+register = template.Library()
+
+
+@register.simple_tag()
+def plugin_list(*args, **kwargs):
+    """ Return a list of all installed integration plugins """
+    return djangosettings.INTEGRATION_PLUGIN_LIST
+
+@register.simple_tag()
+def plugin_settings(plugin, *args, **kwargs):
+    """ Return a list of all settings for a plugin """
+    return djangosettings.INTEGRATION_PLUGIN_SETTING.get(plugin)
diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html
index ebf24bffb1..1b4137a40d 100644
--- a/InvenTree/templates/InvenTree/settings/navbar.html
+++ b/InvenTree/templates/InvenTree/settings/navbar.html
@@ -1,4 +1,5 @@
 {% load i18n %}
+{% load plugin_extras %}
 
 <ul class='list-group'>
 
@@ -116,6 +117,22 @@
         </a>
     </li>
 
+
+    <li class='list-group-item'>
+        <strong>{% trans "Plugin Settings" %}</strong>
+    </li>
+
+    {% plugin_list as pl_list %}
+    {% for plugin_key, plugin in pl_list.items %}
+        {% if plugin.has_settings %}
+            <li class='list-group-item' title='{{ plugin.plugin_name }}'>
+                <a href='#' class='nav-toggle' id='select-plugin-{{plugin_key}}'>
+                    {{ plugin.plugin_name}}
+                </a>
+            </li>
+        {% endif %}
+    {% endfor %}
+
     {% endif %}
 
 </ul>
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
new file mode 100644
index 0000000000..7f4deeace8
--- /dev/null
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -0,0 +1,25 @@
+{% extends "panel.html" %}
+{% load i18n %}
+{% load inventree_extras %}
+{% load plugin_extras %}
+
+{% block label %}plugin-{{plugin_key}}{% endblock %}
+
+
+{% block heading %}
+{% blocktrans with name=plugin.plugin_name %}Plugin Settings for {{name}}{% endblocktrans %}
+{% endblock %}
+
+{% block content %}
+{% plugin_settings plugin_key as plugin_settings %}
+
+<table class='table table-striped table-condensed'>
+    {% include "InvenTree/settings/header.html" %}
+    <tbody>
+        {% for setting in plugin_settings %}
+            {% include "InvenTree/settings/setting.html" with key=setting%}
+        {% endfor %}
+    </tbody>
+</table>
+
+{% endblock %}
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index beb7f5eb04..96b7c35723 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -3,6 +3,7 @@
 {% load i18n %}
 {% load static %}
 {% load inventree_extras %}
+{% load plugin_extras %}
 
 {% block page_title %}
 {% inventree_title %} | {% trans "Settings" %}
@@ -34,6 +35,13 @@
 {% include "InvenTree/settings/po.html" %}
 {% include "InvenTree/settings/so.html" %}
 
+{% plugin_list as pl_list %}
+{% for plugin_key, plugin in pl_list.items %}
+    {% if plugin.has_settings %}
+        {% include "InvenTree/settings/plugin_settings.html" %}
+    {% endif %}
+{% endfor %}
+
 {% endif %}
 
 {% endblock %}

From 733303fb66947395dd92a4874bc6b3a4a1b48e50 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 17 Sep 2021 22:49:43 +0200
Subject: [PATCH 018/493] Plugin overview

---
 .../templates/InvenTree/settings/navbar.html  |  6 ++++
 .../templates/InvenTree/settings/plugin.html  | 34 +++++++++++++++++++
 .../InvenTree/settings/settings.html          |  1 +
 3 files changed, 41 insertions(+)
 create mode 100644 InvenTree/templates/InvenTree/settings/plugin.html

diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html
index 1b4137a40d..5592f63cd9 100644
--- a/InvenTree/templates/InvenTree/settings/navbar.html
+++ b/InvenTree/templates/InvenTree/settings/navbar.html
@@ -122,6 +122,12 @@
         <strong>{% trans "Plugin Settings" %}</strong>
     </li>
 
+    <li class='list-group-item' title='{% trans "Plugin" %}'>
+        <a href='#' class='nav-toggle' id='select-plugin'>
+            <span class='fas fa-plug'></span> {% trans "Plugin" %}
+        </a>
+    </li>
+
     {% plugin_list as pl_list %}
     {% for plugin_key, plugin in pl_list.items %}
         {% if plugin.has_settings %}
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
new file mode 100644
index 0000000000..37b47c2766
--- /dev/null
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -0,0 +1,34 @@
+{% extends "panel.html" %}
+{% load i18n %}
+{% load inventree_extras %}
+{% load plugin_extras %}
+
+{% block label %}plugin{% endblock %}
+
+
+{% block heading %}
+{% trans "Plugin Settings" %}
+{% endblock %}
+
+{% block content %}
+
+<h4>{% trans "Plugin list" %}</h4>
+
+<table class='table table-striped table-condensed'>
+    <thead>
+        <tr>
+            <th>{% trans "Name" %}</th>
+        </tr>
+    </thead>
+    
+    <tbody>
+        {% plugin_list as pl_list %}
+        {% for plugin_key, plugin in pl_list.items %}
+        <tr>
+            <td>{{plugin_key}} - {{ plugin.plugin_name}}</td>
+        </tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+{% endblock %}
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index 96b7c35723..a764b82a39 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -34,6 +34,7 @@
 {% include "InvenTree/settings/build.html" %}
 {% include "InvenTree/settings/po.html" %}
 {% include "InvenTree/settings/so.html" %}
+{% include "InvenTree/settings/plugin.html" %}
 
 {% plugin_list as pl_list %}
 {% for plugin_key, plugin in pl_list.items %}

From 4c7d1e665816e3b8c24e0cda3778cc0e855619ba Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 17 Sep 2021 22:55:02 +0200
Subject: [PATCH 019/493] show enabled functionality in plugin overview

---
 InvenTree/templates/InvenTree/settings/plugin.html | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 37b47c2766..5502a3b716 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -25,7 +25,14 @@
         {% plugin_list as pl_list %}
         {% for plugin_key, plugin in pl_list.items %}
         <tr>
-            <td>{{plugin_key}} - {{ plugin.plugin_name}}</td>
+            <td>{{plugin_key}} - {{ plugin.plugin_name}}
+                {% if plugin.has_urls %}
+                    <span class='badge'>{% trans 'Has urls' %}</span>
+                {% endif %}
+                {% if plugin.has_settings %}
+                    <span class='badge'>{% trans 'Has settings' %}</span>
+                {% endif %}
+            </td>
         </tr>
         {% endfor %}
     </tbody>

From 2498cbde797667823d3aa6a488491eefff9a5971 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 17 Sep 2021 22:59:29 +0200
Subject: [PATCH 020/493] PEP fixes

---
 InvenTree/part/templatetags/plugin_extras.py | 3 +--
 InvenTree/plugins/plugins.py                 | 2 +-
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
index af80c46ccb..f400ab94ad 100644
--- a/InvenTree/part/templatetags/plugin_extras.py
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -2,9 +2,7 @@
 
 """ This module provides template tags for handeling plugins
 """
-from django.utils.translation import ugettext_lazy as _
 from django.conf import settings as djangosettings
-
 from django import template
 
 
@@ -16,6 +14,7 @@ def plugin_list(*args, **kwargs):
     """ Return a list of all installed integration plugins """
     return djangosettings.INTEGRATION_PLUGIN_LIST
 
+
 @register.simple_tag()
 def plugin_settings(plugin, *args, **kwargs):
     """ Return a list of all settings for a plugin """
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 15b5a94c51..0b484b05d0 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -54,7 +54,7 @@ def get_plugins(pkg, baseclass):
     return plugins
 
 
-def load_plugins(name:str, module, cls):
+def load_plugins(name: str, module, cls):
     """general function to load a plugin class
 
     :param name: name of the plugin for logs

From fc3188513b97ec0e564f9627330c34abcc8ab77f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 01:37:53 +0200
Subject: [PATCH 021/493] Links in plugin badges

---
 InvenTree/templates/InvenTree/settings/plugin.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 5502a3b716..515bd41132 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -27,10 +27,10 @@
         <tr>
             <td>{{plugin_key}} - {{ plugin.plugin_name}}
                 {% if plugin.has_urls %}
-                    <span class='badge'>{% trans 'Has urls' %}</span>
+                    <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has urls' %}</a></span>
                 {% endif %}
                 {% if plugin.has_settings %}
-                    <span class='badge'>{% trans 'Has settings' %}</span>
+                    <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has settings' %}</a></span>
                 {% endif %}
             </td>
         </tr>

From ca1fce4cf390cf3bd4d661c8cadb14e71edecb0b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 02:08:33 +0200
Subject: [PATCH 022/493] make plugin path a setting

---
 InvenTree/InvenTree/settings.py | 2 ++
 InvenTree/InvenTree/urls.py     | 2 +-
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index b1bf4ab05f..295bb442c9 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -650,6 +650,8 @@ MESSAGE_TAGS = {
 }
 
 # Plugins
+PLUGIN_URL = 'plugin'
+
 INTEGRATION_PLUGINS = inventree_plugins.load_integration_plugins()
 
 INTEGRATION_PLUGIN_SETTINGS = {}
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 11fa976869..1424a26503 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -179,7 +179,7 @@ urlpatterns = [
     url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
 
     # plugins
-    url(r'^plugin/', include(interation_urls)),
+    url(f'^{settings.PLUGIN_URL}/', include(interation_urls)),
 
     url(r'^markdownx/', include('markdownx.urls')),
 ]

From dafed332bc52514f9402c1a4540123e4a65d3791 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 02:09:22 +0200
Subject: [PATCH 023/493] show base url in settings

---
 InvenTree/plugins/integration/integration.py                | 5 +++++
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 6 ++++++
 2 files changed, 11 insertions(+)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 81d64393b3..4a7b3e19d9 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -3,6 +3,7 @@
 import logging
 
 from django.conf.urls import url, include
+from django.conf import settings
 import plugins.plugin as plugin
 
 
@@ -30,6 +31,10 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
             return self.urlpatterns
         return None
 
+    @property
+    def base_url(self):
+        return f'{settings.PLUGIN_URL}/{self.plugin_name()}/'
+
     @property
     def urlpatterns(self):
         """
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 7f4deeace8..2a84e976af 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -22,4 +22,10 @@
     </tbody>
 </table>
 
+{% if plugin.has_urls %}
+<h4>{% trans "URLs" %}</h4>
+
+    <p>{% blocktrans with base=plugin.base_url %}The Base-URL for this plugin is <strong>{{ base }}</strong>.{% endblocktrans %}</p>
+{% endif %}
+
 {% endblock %}

From 1296e631d92e57b007dd8950a69bf534b7219d6b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 02:15:05 +0200
Subject: [PATCH 024/493] link to base in plugin site

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 2a84e976af..5a6dae2cf2 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -24,8 +24,8 @@
 
 {% if plugin.has_urls %}
 <h4>{% trans "URLs" %}</h4>
-
-    <p>{% blocktrans with base=plugin.base_url %}The Base-URL for this plugin is <strong>{{ base }}</strong>.{% endblocktrans %}</p>
+    {% define plugin.base_url as base %}
+    <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
 {% endif %}
 
 {% endblock %}

From 21dee0d459c2ce77cbd9a2dded57637f661f13b4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 02:47:31 +0200
Subject: [PATCH 025/493] urls overview in plugin settings

---
 .../InvenTree/settings/plugin_settings.html   | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 5a6dae2cf2..c7fd11d4ea 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -26,6 +26,25 @@
 <h4>{% trans "URLs" %}</h4>
     {% define plugin.base_url as base %}
     <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
+
+    <table class='table table-striped table-condensed'>
+        <thead>
+            <tr>
+                <th>{% trans "Name" %}</th>
+                <th>{% trans "URL" %}</th>
+                <th></th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for key, entry in plugin.urlpatterns.reverse_dict.items %}{% if key %}
+            <tr>
+                <td>{{key}}</td>
+                <td>{{entry.1}}</td>
+                <td><a href="/{{ base }}{{entry.1}}">{% trans 'open' %}</a></td>
+            </tr>
+            {% endif %}{% endfor %}
+        </tbody>
+    </table>
 {% endif %}
 
 {% endblock %}

From aa120a819782087245d87da6c41c1c630ed5300b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 02:48:25 +0200
Subject: [PATCH 026/493] open in new tab

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index c7fd11d4ea..f77093b91a 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -25,7 +25,7 @@
 {% if plugin.has_urls %}
 <h4>{% trans "URLs" %}</h4>
     {% define plugin.base_url as base %}
-    <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
+    <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
 
     <table class='table table-striped table-condensed'>
         <thead>
@@ -40,7 +40,7 @@
             <tr>
                 <td>{{key}}</td>
                 <td>{{entry.1}}</td>
-                <td><a href="/{{ base }}{{entry.1}}">{% trans 'open' %}</a></td>
+                <td><a href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
             </tr>
             {% endif %}{% endfor %}
         </tbody>

From b9ba6b9225fe0f1e75550142db697e285ac2f5b0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 02:49:07 +0200
Subject: [PATCH 027/493] make link a button

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 515bd41132..688cb19c45 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -18,6 +18,7 @@
     <thead>
         <tr>
             <th>{% trans "Name" %}</th>
+            <th>{% trans "Author" %}</th>
         </tr>
     </thead>
     
@@ -33,6 +34,7 @@
                     <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has settings' %}</a></span>
                 {% endif %}
             </td>
+            <td># TODO</td>
         </tr>
         {% endfor %}
     </tbody>

From ecc86e098989052a5351a760511f720706d29629 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:27:19 +0200
Subject: [PATCH 028/493] general linting fixes

---
 ci/check_js_templates.py    | 4 +++-
 ci/check_locale_files.py    | 6 +++---
 ci/check_migration_files.py | 2 +-
 ci/check_version_number.py  | 2 +-
 tasks.py                    | 4 ++--
 5 files changed, 10 insertions(+), 8 deletions(-)

diff --git a/ci/check_js_templates.py b/ci/check_js_templates.py
index e3c1f0148f..b9db7fe612 100644
--- a/ci/check_js_templates.py
+++ b/ci/check_js_templates.py
@@ -28,6 +28,7 @@ print("=================================")
 print("Checking static javascript files:")
 print("=================================")
 
+
 def check_invalid_tag(data):
 
     pattern = r"{%(\w+)"
@@ -45,6 +46,7 @@ def check_invalid_tag(data):
 
     return err_count
 
+
 def check_prohibited_tags(data):
 
     allowed_tags = [
@@ -78,7 +80,7 @@ def check_prohibited_tags(data):
                 has_trans = True
 
     if not has_trans:
-        print(f" > file is missing 'trans' tags")
+        print(" > file is missing 'trans' tags")
         err_count += 1
 
     return err_count
diff --git a/ci/check_locale_files.py b/ci/check_locale_files.py
index 9995ceaec5..06246cd923 100644
--- a/ci/check_locale_files.py
+++ b/ci/check_locale_files.py
@@ -24,7 +24,7 @@ for line in str(out.decode()).split('\n'):
 if len(locales) > 0:
     print("There are {n} unstaged locale files:".format(n=len(locales)))
 
-    for l in locales:
-        print(" - {l}".format(l=l))
+    for lang in locales:
+        print(" - {l}".format(l=lang))
 
-sys.exit(len(locales))
\ No newline at end of file
+sys.exit(len(locales))
diff --git a/ci/check_migration_files.py b/ci/check_migration_files.py
index 88e5a90bee..8ef0ada13d 100644
--- a/ci/check_migration_files.py
+++ b/ci/check_migration_files.py
@@ -28,4 +28,4 @@ print("There are {n} unstaged migration files:".format(n=len(migrations)))
 for m in migrations:
     print(" - {m}".format(m=m))
 
-sys.exit(len(migrations))
\ No newline at end of file
+sys.exit(len(migrations))
diff --git a/ci/check_version_number.py b/ci/check_version_number.py
index ca2dbd71c7..6adc36b6d3 100644
--- a/ci/check_version_number.py
+++ b/ci/check_version_number.py
@@ -84,4 +84,4 @@ if __name__ == '__main__':
             print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'")
             sys.exit(1)
 
-sys.exit(0)
\ No newline at end of file
+sys.exit(0)
diff --git a/tasks.py b/tasks.py
index 1abbf23bc6..7f9d155301 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,6 +1,5 @@
 # -*- coding: utf-8 -*-
 
-from shutil import copyfile
 import os
 import json
 import sys
@@ -143,6 +142,7 @@ def clean_settings(c):
 
     manage(c, "clean_settings")
 
+
 @task(post=[rebuild])
 def migrate(c):
     """
@@ -298,7 +298,7 @@ def export_records(c, filename='data.json'):
     # Get an absolute path to the file
     if not os.path.isabs(filename):
         filename = os.path.join(localDir(), filename)
-        filename = os.path.abspath(filename) 
+        filename = os.path.abspath(filename)
 
     print(f"Exporting database records to file '{filename}'")
 

From f74cd5901daf2ba8ee912573d3187e15b7bfe0c3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:30:21 +0200
Subject: [PATCH 029/493] module registry mechanism

---
 InvenTree/plugins/integration/integration.py | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 4a7b3e19d9..49f80d314a 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -16,13 +16,20 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
     """
 
     def __init__(self):
-        """
-        """
-        plugin.InvenTreePlugin.__init__(self)
+        self.add_mixin('base')
+        super().__init__()
 
         self.urls = self.setup_urls()
         self.settings = self.setup_settings()
 
+    def add_mixin(self, key: str):
+        if not hasattr(self, 'mixins'):
+            self.mixins = {}
+        self.mixins[key] = True
+
+    def module(self, key):
+        return key in self.mixins
+
     def setup_urls(self):
         """
         setup url endpoints for this plugin

From 8220ccb385eff0475d7b4d516d7061008021025b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:31:52 +0200
Subject: [PATCH 030/493] plugin settings as a module

---
 InvenTree/InvenTree/settings.py              |  4 +--
 InvenTree/plugins/integration/integration.py | 31 +++++++++++++++++++-
 2 files changed, 32 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 295bb442c9..0ca0c50735 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -660,7 +660,7 @@ INTEGRATION_PLUGIN_LIST = {}
 
 for plugin in INTEGRATION_PLUGINS:
     plugin = plugin()
-    if plugin.has_settings:
-        INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
+    INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
+    if plugin.module('settings') and plugin.has_settings:
         INTEGRATION_PLUGIN_SETTING[plugin.plugin_name()] = plugin.settingspatterns
         INTEGRATION_PLUGIN_SETTINGS.update(plugin.settingspatterns)
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 49f80d314a..f101bcc1d0 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -10,6 +10,36 @@ import plugins.plugin as plugin
 logger = logging.getLogger("inventree")
 
 
+class SettingsMixin:
+    """Mixin that enables settings for the plugin"""
+    def __init__(self):
+        super().__init__()
+
+        self.add_mixin('settings')
+        self.settings = self.setup_settings()
+
+    def setup_settings(self):
+        """
+        setup settings for this plugin
+        """
+        if self.SETTINGS:
+            return self.SETTINGS
+        return None
+
+    @property
+    def has_settings(self):
+        """
+        does this plugin use custom settings
+        """
+        return bool(self.settings)
+
+    @property
+    def settingspatterns(self):
+        if self.has_settings:
+            return {f'PLUGIN_{self.plugin_name().upper()}_{key}': value for key, value in self.settings.items()}
+        return None
+
+
 class IntegrationPlugin(plugin.InvenTreePlugin):
     """
     The IntegrationPlugin class is used to integrate with 3rd party software
@@ -20,7 +50,6 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         super().__init__()
 
         self.urls = self.setup_urls()
-        self.settings = self.setup_settings()
 
     def add_mixin(self, key: str):
         if not hasattr(self, 'mixins'):

From dc70b5ef112764b30e27e8fa0f7e9c5c3a7896d4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:40:21 +0200
Subject: [PATCH 031/493] urls as own mixin

---
 InvenTree/plugins/integration/integration.py | 39 +++++++++++++++++++-
 1 file changed, 37 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index f101bcc1d0..96cac27683 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -40,6 +40,43 @@ class SettingsMixin:
         return None
 
 
+class UrlsMixin:
+    """Mixin that enables urls for the plugin"""
+    def __init__(self):
+        super().__init__()
+
+        self.add_mixin('urls')
+        self.urls = self.setup_urls()
+
+    def setup_urls(self):
+        """
+        setup url endpoints for this plugin
+        """
+        if self.urlpatterns:
+            return self.urlpatterns
+        return None
+
+    @property
+    def base_url(self):
+        return f'{settings.PLUGIN_URL}/{self.plugin_name()}/'
+
+    @property
+    def urlpatterns(self):
+        """
+        retruns the urlpatterns for this plugin
+        """
+        if self.has_urls:
+            return url(f'^{self.plugin_name()}/', include(self.urls), name=self.plugin_name())
+        return None
+
+    @property
+    def has_urls(self):
+        """
+        does this plugin use custom urls
+        """
+        return bool(self.urls)
+
+
 class IntegrationPlugin(plugin.InvenTreePlugin):
     """
     The IntegrationPlugin class is used to integrate with 3rd party software
@@ -49,8 +86,6 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         self.add_mixin('base')
         super().__init__()
 
-        self.urls = self.setup_urls()
-
     def add_mixin(self, key: str):
         if not hasattr(self, 'mixins'):
             self.mixins = {}

From d363063add8b268bade69d348fad680c8784cca6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:41:41 +0200
Subject: [PATCH 032/493] remove duplicate functions

---
 InvenTree/plugins/integration/integration.py | 49 --------------------
 1 file changed, 49 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 96cac27683..c556e34f61 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -93,52 +93,3 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
 
     def module(self, key):
         return key in self.mixins
-
-    def setup_urls(self):
-        """
-        setup url endpoints for this plugin
-        """
-        if self.urlpatterns:
-            return self.urlpatterns
-        return None
-
-    @property
-    def base_url(self):
-        return f'{settings.PLUGIN_URL}/{self.plugin_name()}/'
-
-    @property
-    def urlpatterns(self):
-        """
-        retruns the urlpatterns for this plugin
-        """
-        if self.has_urls:
-            return url(f'^{self.plugin_name()}/', include(self.urls), name=self.plugin_name())
-        return None
-
-    @property
-    def has_urls(self):
-        """
-        does this plugin use custom urls
-        """
-        return bool(self.urls)
-
-    def setup_settings(self):
-        """
-        setup settings for this plugin
-        """
-        if self.SETTINGS:
-            return self.SETTINGS
-        return None
-
-    @property
-    def has_settings(self):
-        """
-        does this plugin use custom settings
-        """
-        return bool(self.settings)
-
-    @property
-    def settingspatterns(self):
-        if self.has_settings:
-            return {f'PLUGIN_{self.plugin_name().upper()}_{key}': value for key, value in self.settings.items()}
-        return None

From 8fb6d7723bfce781db5848bd1143c812dcdbfe99 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:42:27 +0200
Subject: [PATCH 033/493] ensure module

---
 InvenTree/InvenTree/urls.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 1424a26503..b5cd6c691e 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -133,7 +133,7 @@ interation_urls = []
 for plugin in integration_plugins:
     # initialize
     plugin = plugin()
-    if plugin.has_urls:
+    if plugin.module('urls') and plugin.has_urls:
         interation_urls.append(plugin.urlpatterns)
 
 urlpatterns = [

From 0cae3633d22644132852068c1a3f9de5c4429384 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 03:43:09 +0200
Subject: [PATCH 034/493] use button to open plugin urls

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index f77093b91a..7d9ff83de6 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -40,7 +40,7 @@
             <tr>
                 <td>{{key}}</td>
                 <td>{{entry.1}}</td>
-                <td><a href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
+                <td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
             </tr>
             {% endif %}{% endfor %}
         </tbody>

From 21d187a3879e4146af5f7a246c6cda941c8a38cc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 04:18:34 +0200
Subject: [PATCH 035/493] one module check fnc

---
 InvenTree/InvenTree/settings.py              |  2 +-
 InvenTree/InvenTree/urls.py                  |  2 +-
 InvenTree/plugins/integration/integration.py | 18 +++++++++++++-----
 3 files changed, 15 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 0ca0c50735..8a44468c15 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -661,6 +661,6 @@ INTEGRATION_PLUGIN_LIST = {}
 for plugin in INTEGRATION_PLUGINS:
     plugin = plugin()
     INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
-    if plugin.module('settings') and plugin.has_settings:
+    if plugin.module_enabled('settings'):
         INTEGRATION_PLUGIN_SETTING[plugin.plugin_name()] = plugin.settingspatterns
         INTEGRATION_PLUGIN_SETTINGS.update(plugin.settingspatterns)
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index b5cd6c691e..ee26e518ef 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -133,7 +133,7 @@ interation_urls = []
 for plugin in integration_plugins:
     # initialize
     plugin = plugin()
-    if plugin.module('urls') and plugin.has_urls:
+    if plugin.module_enabled('urls'):
         interation_urls.append(plugin.urlpatterns)
 
 urlpatterns = [
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index c556e34f61..8977d7b826 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -15,7 +15,7 @@ class SettingsMixin:
     def __init__(self):
         super().__init__()
 
-        self.add_mixin('settings')
+        self.add_mixin('settings', 'has_settings')
         self.settings = self.setup_settings()
 
     def setup_settings(self):
@@ -45,15 +45,15 @@ class UrlsMixin:
     def __init__(self):
         super().__init__()
 
-        self.add_mixin('urls')
+        self.add_mixin('urls', 'has_urls')
         self.urls = self.setup_urls()
 
     def setup_urls(self):
         """
         setup url endpoints for this plugin
         """
-        if self.urlpatterns:
-            return self.urlpatterns
+        if hasattr(self, 'URLS'):
+            return self.URLS
         return None
 
     @property
@@ -86,10 +86,18 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         self.add_mixin('base')
         super().__init__()
 
-    def add_mixin(self, key: str):
+    def add_mixin(self, key: str, fnc_enabled=None):
         if not hasattr(self, 'mixins'):
             self.mixins = {}
         self.mixins[key] = True
+        if fnc_enabled:
+            self.mixins[key] = fnc_enabled
 
     def module(self, key):
         return key in self.mixins
+
+    def module_enabled(self, key):
+        if self.module(key):
+            fnc_name = self.mixins.get(key)
+            return getattr(self, fnc_name, True)
+        return False

From debad4ab9a86f2fe678089889fa78ca0a14fe960 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 04:23:31 +0200
Subject: [PATCH 036/493] refactor to make simpler

---
 InvenTree/plugins/integration/integration.py | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 8977d7b826..0a315ee051 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -22,9 +22,7 @@ class SettingsMixin:
         """
         setup settings for this plugin
         """
-        if self.SETTINGS:
-            return self.SETTINGS
-        return None
+        return getattr(self, 'SETTINGS', None)
 
     @property
     def has_settings(self):
@@ -52,9 +50,7 @@ class UrlsMixin:
         """
         setup url endpoints for this plugin
         """
-        if hasattr(self, 'URLS'):
-            return self.URLS
-        return None
+        return getattr(self, 'URLS', None)
 
     @property
     def base_url(self):
@@ -86,12 +82,10 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         self.add_mixin('base')
         super().__init__()
 
-    def add_mixin(self, key: str, fnc_enabled=None):
+    def add_mixin(self, key: str, fnc_enabled=True):
         if not hasattr(self, 'mixins'):
             self.mixins = {}
-        self.mixins[key] = True
-        if fnc_enabled:
-            self.mixins[key] = fnc_enabled
+        self.mixins[key] = fnc_enabled
 
     def module(self, key):
         return key in self.mixins

From 0b0c4cf337789060d5063497b89ba5da5de3a8fd Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 04:27:24 +0200
Subject: [PATCH 037/493] more code structure

---
 InvenTree/plugins/integration/integration.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 0a315ee051..c94042113b 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -4,12 +4,14 @@ import logging
 
 from django.conf.urls import url, include
 from django.conf import settings
+
 import plugins.plugin as plugin
 
 
 logger = logging.getLogger("inventree")
 
 
+# region mixins
 class SettingsMixin:
     """Mixin that enables settings for the plugin"""
     def __init__(self):
@@ -71,6 +73,7 @@ class UrlsMixin:
         does this plugin use custom urls
         """
         return bool(self.urls)
+# endregion
 
 
 class IntegrationPlugin(plugin.InvenTreePlugin):

From 1c781d9bf0e0fd7bcfb2a212579c3072cfdb944d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 04:32:31 +0200
Subject: [PATCH 038/493] rename

---
 InvenTree/InvenTree/settings.py              |  2 +-
 InvenTree/InvenTree/urls.py                  |  2 +-
 InvenTree/plugins/integration/integration.py | 16 ++++++++--------
 3 files changed, 10 insertions(+), 10 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 8a44468c15..3388d7087e 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -661,6 +661,6 @@ INTEGRATION_PLUGIN_LIST = {}
 for plugin in INTEGRATION_PLUGINS:
     plugin = plugin()
     INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
-    if plugin.module_enabled('settings'):
+    if plugin.mixin_enabled('settings'):
         INTEGRATION_PLUGIN_SETTING[plugin.plugin_name()] = plugin.settingspatterns
         INTEGRATION_PLUGIN_SETTINGS.update(plugin.settingspatterns)
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index ee26e518ef..3f018e15ad 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -133,7 +133,7 @@ interation_urls = []
 for plugin in integration_plugins:
     # initialize
     plugin = plugin()
-    if plugin.module_enabled('urls'):
+    if plugin.mixin_enabled('urls'):
         interation_urls.append(plugin.urlpatterns)
 
 urlpatterns = [
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index c94042113b..651abeb928 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -86,15 +86,15 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
         super().__init__()
 
     def add_mixin(self, key: str, fnc_enabled=True):
-        if not hasattr(self, 'mixins'):
-            self.mixins = {}
-        self.mixins[key] = fnc_enabled
+        if not hasattr(self, '_mixins'):
+            self._mixins = {}
+        self._mixins[key] = fnc_enabled
 
-    def module(self, key):
-        return key in self.mixins
+    def mixin(self, key):
+        return key in self._mixins
 
-    def module_enabled(self, key):
-        if self.module(key):
-            fnc_name = self.mixins.get(key)
+    def mixin_enabled(self, key):
+        if self.mixin(key):
+            fnc_name = self._mixins.get(key)
             return getattr(self, fnc_name, True)
         return False

From 3b2cb43ece01de30c4c1e070b46440946a3c1695 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 18 Sep 2021 04:46:50 +0200
Subject: [PATCH 039/493] mixin_enabled check for templates

---
 InvenTree/part/templatetags/plugin_extras.py           |  6 ++++++
 InvenTree/templates/InvenTree/settings/plugin.html     |  7 +++++--
 .../templates/InvenTree/settings/plugin_settings.html  | 10 ++++++++--
 3 files changed, 19 insertions(+), 4 deletions(-)

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
index f400ab94ad..6f214340f7 100644
--- a/InvenTree/part/templatetags/plugin_extras.py
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -19,3 +19,9 @@ def plugin_list(*args, **kwargs):
 def plugin_settings(plugin, *args, **kwargs):
     """ Return a list of all settings for a plugin """
     return djangosettings.INTEGRATION_PLUGIN_SETTING.get(plugin)
+
+
+@register.simple_tag()
+def mixin_enabled(plugin, key, *args, **kwargs):
+    """ Return if the mixin is existant and configured in the plugin """
+    return plugin.mixin_enabled(key)
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 688cb19c45..44b486abae 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -25,12 +25,15 @@
     <tbody>
         {% plugin_list as pl_list %}
         {% for plugin_key, plugin in pl_list.items %}
+        {% mixin_enabled plugin 'urls' as urls %}
+        {% mixin_enabled plugin 'settings' as settings %}
+
         <tr>
             <td>{{plugin_key}} - {{ plugin.plugin_name}}
-                {% if plugin.has_urls %}
+                {% if urls %}
                     <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has urls' %}</a></span>
                 {% endif %}
-                {% if plugin.has_settings %}
+                {% if settings %}
                     <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has settings' %}</a></span>
                 {% endif %}
             </td>
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 7d9ff83de6..4f0d6d7f35 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -7,10 +7,14 @@
 
 
 {% block heading %}
-{% blocktrans with name=plugin.plugin_name %}Plugin Settings for {{name}}{% endblocktrans %}
+{% blocktrans with name=plugin.plugin_name %}Plugin details for {{name}}{% endblocktrans %}
 {% endblock %}
 
 {% block content %}
+
+{% mixin_enabled plugin 'settings' as settings %}
+{% if settings %}
+<h4>{% trans "Settings" %}</h4>
 {% plugin_settings plugin_key as plugin_settings %}
 
 <table class='table table-striped table-condensed'>
@@ -21,8 +25,10 @@
         {% endfor %}
     </tbody>
 </table>
+{% endif %}
 
-{% if plugin.has_urls %}
+{% mixin_enabled plugin 'urls' as urls %}
+{% if urls %}
 <h4>{% trans "URLs" %}</h4>
     {% define plugin.base_url as base %}
     <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>

From c222b9c296f85ce5df3d96ad7c6d462a2e3531ba Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 14:46:32 +0200
Subject: [PATCH 040/493] Some sample code for internal testing

---
 InvenTree/plugins/integration/__init__.py     |  0
 .../plugins/integration/another_sample.py     | 20 +++++++
 InvenTree/plugins/integration/sample.py       | 58 +++++++++++++++++++
 3 files changed, 78 insertions(+)
 create mode 100644 InvenTree/plugins/integration/__init__.py
 create mode 100644 InvenTree/plugins/integration/another_sample.py
 create mode 100644 InvenTree/plugins/integration/sample.py

diff --git a/InvenTree/plugins/integration/__init__.py b/InvenTree/plugins/integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/plugins/integration/another_sample.py b/InvenTree/plugins/integration/another_sample.py
new file mode 100644
index 0000000000..28d7cf5249
--- /dev/null
+++ b/InvenTree/plugins/integration/another_sample.py
@@ -0,0 +1,20 @@
+from plugins.integration.integration import *
+
+from django.http import HttpResponse
+from django.utils.translation import ugettext_lazy as _
+
+
+class NoIntegrationPlugin(IntegrationPlugin):
+    """
+    An basic integration plugin
+    """
+
+    PLUGIN_NAME = "NoIntegrationPlugin"
+
+
+class WrongIntegrationPlugin(UrlsMixin, IntegrationPlugin):
+    """
+    An basic integration plugin
+    """
+
+    PLUGIN_NAME = "WrongIntegrationPlugin"
diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
new file mode 100644
index 0000000000..8ade4373bb
--- /dev/null
+++ b/InvenTree/plugins/integration/sample.py
@@ -0,0 +1,58 @@
+from plugins.integration.integration import *
+
+from django.http import HttpResponse
+from django.utils.translation import ugettext_lazy as _
+
+
+class SimpleIntegrationPlugin(SettingsMixin, UrlsMixin, IntegrationPlugin):
+    """
+    An basic integration plugin
+    """
+
+    PLUGIN_NAME = "SimpleIntegrationPlugin"
+
+    def view_test(self, request):
+        return HttpResponse(f'Hi there {request.user.username} this works')
+
+    def setup_urls(self):
+        he = [
+            url(r'^he/', self.view_test, name='he'),
+            url(r'^ha/', self.view_test, name='ha'),
+        ]
+
+        return [
+            url(r'^hi/', self.view_test, name='hi'),
+            url(r'^ho/', include(he), name='ho'),
+        ]
+
+    SETTINGS = {
+        'PO_FUNCTION_ENABLE': {
+            'name': _('Enable PO'),
+            'description': _('Enable PO functionality in InvenTree interface'),
+            'default': True,
+            'validator': bool,
+        },
+    }
+
+
+class OtherIntegrationPlugin(UrlsMixin, IntegrationPlugin):
+    """
+    An basic integration plugin
+    """
+
+    PLUGIN_NAME = "OtherIntegrationPlugin"
+
+    # @cls_login_required()
+    def view_test(self, request):
+        return HttpResponse(f'Hi there {request.user.username} this works')
+
+    def setup_urls(self):
+        he = [
+            url(r'^he/', self.view_test, name='he'),
+            url(r'^ha/', self.view_test, name='ha'),
+        ]
+
+        return [
+            url(r'^hi/', self.view_test, name='hi'),
+            url(r'^ho/', include(he), name='ho'),
+        ]

From 4c8318440c636cfdf09b6e08d02b53bbdc4608f0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 14:54:55 +0200
Subject: [PATCH 041/493] renmae of plugin

---
 InvenTree/plugins/integration/sample.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index 8ade4373bb..86e0cf0eea 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -4,12 +4,12 @@ from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
 
 
-class SimpleIntegrationPlugin(SettingsMixin, UrlsMixin, IntegrationPlugin):
+class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, IntegrationPlugin):
     """
-    An basic integration plugin
+    An full integration plugin
     """
 
-    PLUGIN_NAME = "SimpleIntegrationPlugin"
+    PLUGIN_NAME = "SampleIntegrationPlugin"
 
     def view_test(self, request):
         return HttpResponse(f'Hi there {request.user.username} this works')

From 1c89e83d28749501f9cac1372e6ff1c6006ef2e3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 16:27:43 +0200
Subject: [PATCH 042/493] navigation plugin

---
 InvenTree/plugins/integration/integration.py | 21 ++++++++++++++++++++
 InvenTree/plugins/integration/sample.py      |  6 +++++-
 InvenTree/templates/navbar.html              | 18 +++++++++++++++++
 3 files changed, 44 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 651abeb928..5690dfe2be 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -73,6 +73,27 @@ class UrlsMixin:
         does this plugin use custom urls
         """
         return bool(self.urls)
+
+
+class NavigationMixin:
+    """Mixin that enables adding navigation links with the plugin"""
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('navigation', 'has_naviation')
+        self.navigation = self.setup_navigation()
+
+    def setup_navigation(self):
+        """
+        setup navigation links for this plugin
+        """
+        return getattr(self, 'NAVIGATION', None)
+
+    @property
+    def has_naviation(self):
+        """
+        does this plugin define navigation elements
+        """
+        return bool(self.navigation)
 # endregion
 
 
diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index 86e0cf0eea..9096482648 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -4,7 +4,7 @@ from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
 
 
-class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, IntegrationPlugin):
+class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin):
     """
     An full integration plugin
     """
@@ -34,6 +34,10 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, IntegrationPlugin):
         },
     }
 
+    NAVIGATION= [
+        {'name': 'SampleIntegration', 'link': 'SampleIntegrationPlugin:hi'},
+    ]
+
 
 class OtherIntegrationPlugin(UrlsMixin, IntegrationPlugin):
     """
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index b109bd9daf..369218b524 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -1,5 +1,6 @@
 {% load static %}
 {% load inventree_extras %}
+{% load plugin_extras %}
 {% load i18n %}
 
 {% settings_value 'BARCODE_ENABLE' as barcodes %}
@@ -57,6 +58,23 @@
           </ul>
         </li>
         {% endif %}
+
+        {% plugin_list as pl_list %}
+        {% for plugin_key, plugin in pl_list.items %}
+          {% mixin_enabled plugin 'navigation' as navigation %}
+          {% if navigation %}
+
+          <li class='nav navbar-nav'>
+            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.plugin_name}}</a>
+            <ul class='dropdown-menu'>
+             {% for nav_item in plugin.navigation %}
+                <li><a href="{% url nav_item.name %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
+             {% endfor %}
+            </ul>
+          </li>
+          {% endif %}
+        {% endfor %}
+
       </ul>
       <ul class="nav navbar-nav navbar-right">
           {% include "search_form.html" %}

From 22ee631af5572d2569142464ff704e8071cf75d7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 16:41:55 +0200
Subject: [PATCH 043/493] fixing wrong link

---
 InvenTree/templates/navbar.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index 369218b524..2aabb0945c 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -68,7 +68,7 @@
             <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.plugin_name}}</a>
             <ul class='dropdown-menu'>
              {% for nav_item in plugin.navigation %}
-                <li><a href="{% url nav_item.name %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
+                <li><a href="{% url nav_item.link %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
              {% endfor %}
             </ul>
           </li>

From 3edabc810e1e85e72a3612c77247a1302c660e42 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 16:44:32 +0200
Subject: [PATCH 044/493] use namespaces for urls

---
 InvenTree/InvenTree/urls.py                  | 2 +-
 InvenTree/plugins/integration/integration.py | 2 +-
 InvenTree/plugins/integration/sample.py      | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 3f018e15ad..0d75fb2938 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -179,7 +179,7 @@ urlpatterns = [
     url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
 
     # plugins
-    url(f'^{settings.PLUGIN_URL}/', include(interation_urls)),
+    url(f'^{settings.PLUGIN_URL}/', include((interation_urls, 'plugin'))),
 
     url(r'^markdownx/', include('markdownx.urls')),
 ]
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 5690dfe2be..695cf02067 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -64,7 +64,7 @@ class UrlsMixin:
         retruns the urlpatterns for this plugin
         """
         if self.has_urls:
-            return url(f'^{self.plugin_name()}/', include(self.urls), name=self.plugin_name())
+            return url(f'^{self.plugin_name()}/', include((self.urls, self.plugin_name())), name=self.plugin_name())
         return None
 
     @property
diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index 9096482648..f184aa00cd 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -35,7 +35,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     }
 
     NAVIGATION= [
-        {'name': 'SampleIntegration', 'link': 'SampleIntegrationPlugin:hi'},
+        {'name': 'SampleIntegration', 'link': 'plugin:SampleIntegrationPlugin:hi'},
     ]
 
 

From 063a0e51420af9b5b991d6cbfc63adc3e169d0d1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 16:52:31 +0200
Subject: [PATCH 045/493] Link preflight check

---
 InvenTree/plugins/integration/integration.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 695cf02067..f1e988c562 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -86,7 +86,12 @@ class NavigationMixin:
         """
         setup navigation links for this plugin
         """
-        return getattr(self, 'NAVIGATION', None)
+        nav_links = getattr(self, 'NAVIGATION', None)
+        if nav_links:
+            for link in nav_links:
+                if False in [a in link for a in ('link', 'name', )]:
+                    raise NotImplementedError('Wrong Link definition', link)
+        return nav_links
 
     @property
     def has_naviation(self):

From 5ce525400ddebc4614869c806e64cd5f783ef267 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 16:55:24 +0200
Subject: [PATCH 046/493] small refactor

---
 InvenTree/plugins/integration/integration.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index f1e988c562..21aa75d511 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -16,7 +16,6 @@ class SettingsMixin:
     """Mixin that enables settings for the plugin"""
     def __init__(self):
         super().__init__()
-
         self.add_mixin('settings', 'has_settings')
         self.settings = self.setup_settings()
 
@@ -44,7 +43,6 @@ class UrlsMixin:
     """Mixin that enables urls for the plugin"""
     def __init__(self):
         super().__init__()
-
         self.add_mixin('urls', 'has_urls')
         self.urls = self.setup_urls()
 
@@ -88,6 +86,7 @@ class NavigationMixin:
         """
         nav_links = getattr(self, 'NAVIGATION', None)
         if nav_links:
+            # check if needed values are configured
             for link in nav_links:
                 if False in [a in link for a in ('link', 'name', )]:
                     raise NotImplementedError('Wrong Link definition', link)
@@ -109,7 +108,6 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
 
     def __init__(self):
         self.add_mixin('base')
-        super().__init__()
 
     def add_mixin(self, key: str, fnc_enabled=True):
         if not hasattr(self, '_mixins'):

From 369f92abf166227f24eb0423c34331905b931d36 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 17:12:16 +0200
Subject: [PATCH 047/493] move mixin registry stuff into own class

---
 InvenTree/plugins/integration/integration.py | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 21aa75d511..23831a747e 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -11,6 +11,15 @@ import plugins.plugin as plugin
 logger = logging.getLogger("inventree")
 
 
+class MixinBase:
+    """general base for mixins"""
+
+    def add_mixin(self, key: str, fnc_enabled=True, cls=None):
+        if not hasattr(self, '_mixins'):
+            self._mixins = {}
+        self._mixins[key] = fnc_enabled
+
+
 # region mixins
 class SettingsMixin:
     """Mixin that enables settings for the plugin"""
@@ -101,7 +110,7 @@ class NavigationMixin:
 # endregion
 
 
-class IntegrationPlugin(plugin.InvenTreePlugin):
+class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """
     The IntegrationPlugin class is used to integrate with 3rd party software
     """
@@ -109,11 +118,6 @@ class IntegrationPlugin(plugin.InvenTreePlugin):
     def __init__(self):
         self.add_mixin('base')
 
-    def add_mixin(self, key: str, fnc_enabled=True):
-        if not hasattr(self, '_mixins'):
-            self._mixins = {}
-        self._mixins[key] = fnc_enabled
-
     def mixin(self, key):
         return key in self._mixins
 

From 6f8909c710aa5fd12a40d00cfef0686de70b17ce Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 17:32:44 +0200
Subject: [PATCH 048/493] mixin registry

---
 InvenTree/plugins/integration/integration.py  | 35 +++++++++++++++++--
 .../templates/InvenTree/settings/plugin.html  |  8 +++++
 2 files changed, 40 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 23831a747e..b4361cb416 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -18,14 +18,37 @@ class MixinBase:
         if not hasattr(self, '_mixins'):
             self._mixins = {}
         self._mixins[key] = fnc_enabled
+        self.setup_mixin(key, cls=cls)
 
+    def setup_mixin(self, key, cls=None):
+        if not hasattr(self, '_mixinreg'):
+            self._mixinreg = {}
+
+        # get human name
+        human_name = getattr(cls.Meta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'Meta') else key
+
+        # register
+        self._mixinreg[key] = {
+            'key': key,
+            'human_name': human_name,
+        }
+
+    @property
+    def registered_mixins(self):
+        mxins =  getattr(self, '_mixinreg', None)
+        if mxins:
+            mxins = [a for a in mxins.values()]
+        return mxins
 
 # region mixins
 class SettingsMixin:
     """Mixin that enables settings for the plugin"""
+    class Meta:
+        MIXIN_NAME = 'Settings'
+
     def __init__(self):
         super().__init__()
-        self.add_mixin('settings', 'has_settings')
+        self.add_mixin('settings', 'has_settings', __class__)
         self.settings = self.setup_settings()
 
     def setup_settings(self):
@@ -50,9 +73,12 @@ class SettingsMixin:
 
 class UrlsMixin:
     """Mixin that enables urls for the plugin"""
+    class Meta:
+        MIXIN_NAME = 'URLs'
+
     def __init__(self):
         super().__init__()
-        self.add_mixin('urls', 'has_urls')
+        self.add_mixin('urls', 'has_urls', __class__)
         self.urls = self.setup_urls()
 
     def setup_urls(self):
@@ -84,9 +110,12 @@ class UrlsMixin:
 
 class NavigationMixin:
     """Mixin that enables adding navigation links with the plugin"""
+    class Meta:
+        MIXIN_NAME = 'Navigation Links'
+
     def __init__(self):
         super().__init__()
-        self.add_mixin('navigation', 'has_naviation')
+        self.add_mixin('navigation', 'has_naviation', __class__)
         self.navigation = self.setup_navigation()
 
     def setup_navigation(self):
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 44b486abae..0b2d5bcc02 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -36,6 +36,14 @@
                 {% if settings %}
                     <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has settings' %}</a></span>
                 {% endif %}
+
+                {% define plugin.registered_mixins as mixin_list %}
+                {% if mixin_list %}
+                {% for mixin in mixin_list %}
+                    <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>
+                        {% blocktrans with name=mixin.human_name%}has {{name}}{% endblocktrans %}</a></span>
+                {% endfor %}
+                {% endif %}
             </td>
             <td># TODO</td>
         </tr>

From 7130bacf952b2ed01f976af794756b753898db56 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 17:34:06 +0200
Subject: [PATCH 049/493] filter out base by default

---
 InvenTree/plugins/integration/integration.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index b4361cb416..f245b94ef2 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -34,9 +34,13 @@ class MixinBase:
         }
 
     @property
-    def registered_mixins(self):
+    def registered_mixins(self, with_base: bool=False):
         mxins =  getattr(self, '_mixinreg', None)
         if mxins:
+            # filter out base
+            if not with_base and 'base' in mxins:
+                del mxins['base']
+            # only return dict
             mxins = [a for a in mxins.values()]
         return mxins
 

From 2bb68fadf7c1f5594b1d23e0eb47ea85a865bb31 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 17:34:20 +0200
Subject: [PATCH 050/493] name refactor

---
 InvenTree/plugins/integration/integration.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index f245b94ef2..e24c1d79b5 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -35,14 +35,14 @@ class MixinBase:
 
     @property
     def registered_mixins(self, with_base: bool=False):
-        mxins =  getattr(self, '_mixinreg', None)
-        if mxins:
+        mixins =  getattr(self, '_mixinreg', None)
+        if mixins:
             # filter out base
-            if not with_base and 'base' in mxins:
-                del mxins['base']
+            if not with_base and 'base' in mixins:
+                del mixins['base']
             # only return dict
-            mxins = [a for a in mxins.values()]
-        return mxins
+            mixins = [a for a in mixins.values()]
+        return mixins
 
 # region mixins
 class SettingsMixin:

From fc691ebbfbc11e2d9869949c505dee7feba0f869 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 17:36:30 +0200
Subject: [PATCH 051/493] only show plugins with enabled mixins

---
 InvenTree/templates/InvenTree/settings/navbar.html   | 2 +-
 InvenTree/templates/InvenTree/settings/settings.html | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html
index 5592f63cd9..512c397292 100644
--- a/InvenTree/templates/InvenTree/settings/navbar.html
+++ b/InvenTree/templates/InvenTree/settings/navbar.html
@@ -130,7 +130,7 @@
 
     {% plugin_list as pl_list %}
     {% for plugin_key, plugin in pl_list.items %}
-        {% if plugin.has_settings %}
+        {% if plugin.registered_mixins %}
             <li class='list-group-item' title='{{ plugin.plugin_name }}'>
                 <a href='#' class='nav-toggle' id='select-plugin-{{plugin_key}}'>
                     {{ plugin.plugin_name}}
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index a764b82a39..c1a7985e94 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -38,7 +38,7 @@
 
 {% plugin_list as pl_list %}
 {% for plugin_key, plugin in pl_list.items %}
-    {% if plugin.has_settings %}
+    {% if plugin.registered_mixins %}
         {% include "InvenTree/settings/plugin_settings.html" %}
     {% endif %}
 {% endfor %}

From fc9796ae826dec1e43eb5159beb1da7eec0dbf0c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 23:38:27 +0200
Subject: [PATCH 052/493] fix imports

---
 InvenTree/plugins/integration/another_sample.py | 5 +----
 InvenTree/plugins/integration/sample.py         | 5 +++--
 2 files changed, 4 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/integration/another_sample.py b/InvenTree/plugins/integration/another_sample.py
index 28d7cf5249..a52fd6d32f 100644
--- a/InvenTree/plugins/integration/another_sample.py
+++ b/InvenTree/plugins/integration/another_sample.py
@@ -1,7 +1,4 @@
-from plugins.integration.integration import *
-
-from django.http import HttpResponse
-from django.utils.translation import ugettext_lazy as _
+from plugins.integration.integration import IntegrationPlugin, UrlsMixin
 
 
 class NoIntegrationPlugin(IntegrationPlugin):
diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index f184aa00cd..d4c2ea43de 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -1,7 +1,8 @@
-from plugins.integration.integration import *
+from plugins.integration.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
+from django.conf.urls import url, include
 
 
 class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin):
@@ -34,7 +35,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
         },
     }
 
-    NAVIGATION= [
+    NAVIGATION = [
         {'name': 'SampleIntegration', 'link': 'plugin:SampleIntegrationPlugin:hi'},
     ]
 

From 0b2631c785c0781e2bb25a02cad6400a3d7fd74f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 19 Sep 2021 23:39:02 +0200
Subject: [PATCH 053/493] PEP sytle fix

---
 InvenTree/plugins/integration/integration.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index e24c1d79b5..e4f1b68cd7 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -34,8 +34,8 @@ class MixinBase:
         }
 
     @property
-    def registered_mixins(self, with_base: bool=False):
-        mixins =  getattr(self, '_mixinreg', None)
+    def registered_mixins(self, with_base: bool = False):
+        mixins = getattr(self, '_mixinreg', None)
         if mixins:
             # filter out base
             if not with_base and 'base' in mixins:
@@ -44,6 +44,7 @@ class MixinBase:
             mixins = [a for a in mixins.values()]
         return mixins
 
+
 # region mixins
 class SettingsMixin:
     """Mixin that enables settings for the plugin"""

From 2960e4486b69b09eb2e9f10d76b9ed270e2e46b6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 20 Sep 2021 07:24:40 +0200
Subject: [PATCH 054/493] moved barcode loading

---
 InvenTree/barcodes/api.py     |  3 ++-
 InvenTree/barcodes/barcode.py | 23 -----------------------
 InvenTree/plugins/plugins.py  | 11 +++++++++++
 3 files changed, 13 insertions(+), 24 deletions(-)

diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py
index 6ab848c3f6..dd596de794 100644
--- a/InvenTree/barcodes/api.py
+++ b/InvenTree/barcodes/api.py
@@ -12,7 +12,8 @@ from rest_framework.views import APIView
 from stock.models import StockItem
 from stock.serializers import StockItemSerializer
 
-from barcodes.barcode import load_barcode_plugins, hash_barcode
+from barcodes.barcode import hash_barcode
+from plugins.plugins import load_barcode_plugins
 
 
 class BarcodeScan(APIView):
diff --git a/InvenTree/barcodes/barcode.py b/InvenTree/barcodes/barcode.py
index 7ab9f3716a..51f8a1ffa1 100644
--- a/InvenTree/barcodes/barcode.py
+++ b/InvenTree/barcodes/barcode.py
@@ -4,8 +4,6 @@ import string
 import hashlib
 import logging
 
-from InvenTree import plugins as InvenTreePlugins
-from barcodes import plugins as BarcodePlugins
 
 from stock.models import StockItem
 from stock.serializers import StockItemSerializer, LocationSerializer
@@ -139,24 +137,3 @@ class BarcodePlugin:
         Default implementation returns False
         """
         return False
-
-
-def load_barcode_plugins(debug=False):
-    """
-    Function to load all barcode plugins
-    """
-
-    logger.debug("Loading barcode plugins")
-
-    plugins = InvenTreePlugins.get_plugins(BarcodePlugins, BarcodePlugin)
-
-    if debug:
-        if len(plugins) > 0:
-            logger.info(f"Discovered {len(plugins)} barcode plugins")
-
-            for p in plugins:
-                logger.debug(" - {p}".format(p=p.PLUGIN_NAME))
-        else:
-            logger.debug("No barcode plugins found")
-
-    return plugins
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 0b484b05d0..708a2ee2df 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -88,3 +88,14 @@ def load_integration_plugins():
     Return a list of all registered integration plugins
     """
     return load_plugins('integration', integration, IntegrationPlugin)
+
+
+def load_barcode_plugins():
+    """
+    Return a list of all registered barcode plugins
+    """
+    from barcodes import plugins as BarcodePlugins
+    from barcodes.barcode import BarcodePlugin
+
+    return load_plugins('barcode', BarcodePlugins, BarcodePlugin)
+

From fddbaa462970762a60b51d853d9d182302db15b1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 00:19:51 +0200
Subject: [PATCH 055/493] add git commit readout

---
 InvenTree/plugins/integration/integration.py  | 19 +++++++++++++++++++
 .../templates/InvenTree/settings/plugin.html  |  2 ++
 2 files changed, 21 insertions(+)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index e4f1b68cd7..327786f1c6 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -1,6 +1,9 @@
 # -*- coding: utf-8 -*-
 
 import logging
+import os
+import subprocess
+import inspect
 
 from django.conf.urls import url, include
 from django.conf import settings
@@ -144,6 +147,17 @@ class NavigationMixin:
 # endregion
 
 
+def get_git_log(path):
+    path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
+    command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%ad%n%f'", '--follow', '--', path]
+    try:
+        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR), stderr=subprocess.STDOUT), 'utf-8').split('\n')
+    except subprocess.CalledProcessError as _e:
+        print(_e)
+        output = 5 * ['']
+    return {'commit': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]}
+
+
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """
     The IntegrationPlugin class is used to integrate with 3rd party software
@@ -151,6 +165,7 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
 
     def __init__(self):
         self.add_mixin('base')
+        self.commit = self.get_plugin_commit()
 
     def mixin(self, key):
         return key in self._mixins
@@ -160,3 +175,7 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
             fnc_name = self._mixins.get(key)
             return getattr(self, fnc_name, True)
         return False
+
+    def get_plugin_commit(self):
+        path = inspect.getfile(self.__class__)
+        return get_git_log(path)
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 0b2d5bcc02..30db6966c8 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -19,6 +19,7 @@
         <tr>
             <th>{% trans "Name" %}</th>
             <th>{% trans "Author" %}</th>
+            <th>{% trans "Date" %}</th>
         </tr>
     </thead>
     
@@ -46,6 +47,7 @@
                 {% endif %}
             </td>
             <td># TODO</td>
+            <td>{{plugin.commit.date}}</td>
         </tr>
         {% endfor %}
     </tbody>

From 933e572a8d8e741d43bdf96e381ec29d74b17d90 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 00:20:06 +0200
Subject: [PATCH 056/493] refactor

---
 InvenTree/plugins/plugins.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 708a2ee2df..b06957ae07 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -98,4 +98,3 @@ def load_barcode_plugins():
     from barcodes.barcode import BarcodePlugin
 
     return load_plugins('barcode', BarcodePlugins, BarcodePlugin)
-

From 7f00005cf6ca7a87d5b2d6357f10fe86eff0ef2f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 07:48:12 +0200
Subject: [PATCH 057/493] version infromation for each plugin

---
 .../InvenTree/settings/plugin_settings.html   | 26 +++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 4f0d6d7f35..5bbfa22214 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -12,6 +12,32 @@
 
 {% block content %}
 
+
+<table class='table table-striped table-condensed'>
+    <col width='25'>
+    <tr>
+        <td><span class='fas fa-hashtag'></span></td>
+        <td>{% trans "Plugin Version" %}</td>
+        <td></td>
+    </tr>
+    <tr>
+        <td><span class='fas fa-code-branch'></span></td>
+        <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.commit }}{% include "clip.html" %}</td>
+    </tr>
+    <tr>
+        <td><span class='fas fa-calendar-alt'></span></td>
+        <td>{% trans "Commit Date" %}</td><td>{{ plugin.commit.date }}{% include "clip.html" %}</td>
+    </tr>
+    <tr>
+        <td><span class='fas fa-user'></span></td>
+        <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
+    </tr>
+    <tr>
+        <td><span class='fas fa-mail'></span></td>
+        <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
+    </tr>
+</table>
+
 {% mixin_enabled plugin 'settings' as settings %}
 {% if settings %}
 <h4>{% trans "Settings" %}</h4>

From a26036a4f9b563b09d1956c5c165e8e2d80a3a5a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 07:55:08 +0200
Subject: [PATCH 058/493] better formatting

---
 .../InvenTree/settings/plugin_settings.html   | 56 ++++++++++---------
 1 file changed, 31 insertions(+), 25 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 5bbfa22214..ff639dda9a 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -12,31 +12,37 @@
 
 {% block content %}
 
-
-<table class='table table-striped table-condensed'>
-    <col width='25'>
-    <tr>
-        <td><span class='fas fa-hashtag'></span></td>
-        <td>{% trans "Plugin Version" %}</td>
-        <td></td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-code-branch'></span></td>
-        <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.commit }}{% include "clip.html" %}</td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-calendar-alt'></span></td>
-        <td>{% trans "Commit Date" %}</td><td>{{ plugin.commit.date }}{% include "clip.html" %}</td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-user'></span></td>
-        <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
-    </tr>
-    <tr>
-        <td><span class='fas fa-mail'></span></td>
-        <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
-    </tr>
-</table>
+<div class="row">
+    <div class="col-md-6">
+        <table class='table table-striped table-condensed'>
+            <col width='25'>
+            <tr>
+                <td><span class='fas fa-hashtag'></span></td>
+                <td>{% trans "Plugin Version" %}</td>
+                <td></td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-code-branch'></span></td>
+                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.commit }}{% include "clip.html" %}</td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-calendar-alt'></span></td>
+                <td>{% trans "Commit Date" %}</td><td>{{ plugin.commit.date }}{% include "clip.html" %}</td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-user'></span></td>
+                <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-mail'></span></td>
+                <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
+            </tr>
+        </table>
+    </div>
+    <div class="col-md-6">
+        <p>{% trans 'This information is pulled from the last git commit for this plugin. It might not reflect official version numbers.' %}</p>
+    </div>
+</div>
 
 {% mixin_enabled plugin 'settings' as settings %}
 {% if settings %}

From f80a3312ecbb449016724309685b1f9f8c596cfe Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 07:55:47 +0200
Subject: [PATCH 059/493] name refactor to make more concise

---
 InvenTree/plugins/integration/integration.py                | 2 +-
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 327786f1c6..8e5175b0a6 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -155,7 +155,7 @@ def get_git_log(path):
     except subprocess.CalledProcessError as _e:
         print(_e)
         output = 5 * ['']
-    return {'commit': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]}
+    return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]}
 
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index ff639dda9a..dc416971de 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -23,7 +23,7 @@
             </tr>
             <tr>
                 <td><span class='fas fa-code-branch'></span></td>
-                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.commit }}{% include "clip.html" %}</td>
+                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.hash }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='fas fa-calendar-alt'></span></td>

From 33465890ed845d53f744d75a26fa26bde1856726 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 07:59:37 +0200
Subject: [PATCH 060/493] fix mail icon

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index dc416971de..916e7bfa0e 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -34,7 +34,7 @@
                 <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
             </tr>
             <tr>
-                <td><span class='fas fa-mail'></span></td>
+                <td><span class='fas fa-envelope'></span></td>
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
             </tr>
         </table>

From 4a9eab6d328f36d528854b34229c9197aea009c1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 22 Sep 2021 08:00:28 +0200
Subject: [PATCH 061/493] small rename

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 916e7bfa0e..e5f209b17e 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -40,7 +40,7 @@
         </table>
     </div>
     <div class="col-md-6">
-        <p>{% trans 'This information is pulled from the last git commit for this plugin. It might not reflect official version numbers.' %}</p>
+        <p>{% trans 'This information is pulled from the latest git commit for this plugin. It might not reflect official version numbers.' %}</p>
     </div>
 </div>
 

From 5d285d12f7384023aa4506d91d819c99da83fa00 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 00:48:56 +0200
Subject: [PATCH 062/493] remove debug code

---
 InvenTree/plugins/integration/integration.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 8e5175b0a6..a5cfb2f15c 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -151,9 +151,8 @@ def get_git_log(path):
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
     command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%ad%n%f'", '--follow', '--', path]
     try:
-        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR), stderr=subprocess.STDOUT), 'utf-8').split('\n')
-    except subprocess.CalledProcessError as _e:
-        print(_e)
+        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8').split('\n')
+    except subprocess.CalledProcessError:
         output = 5 * ['']
     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]}
 

From 4fb3fffbe6eac5dc6a1b9399cb7029e7bbb04805 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 00:49:42 +0200
Subject: [PATCH 063/493] remove string chars

---
 InvenTree/plugins/integration/integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index a5cfb2f15c..0000c17249 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -151,7 +151,7 @@ def get_git_log(path):
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
     command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%ad%n%f'", '--follow', '--', path]
     try:
-        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8').split('\n')
+        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1].split('\n')
     except subprocess.CalledProcessError:
         output = 5 * ['']
     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]}

From 680a7071e5e27691893cb8816909ace5efdf771c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 00:52:32 +0200
Subject: [PATCH 064/493] check git verification state

---
 InvenTree/plugins/integration/integration.py              | 6 +++---
 .../templates/InvenTree/settings/plugin_settings.html     | 8 ++++++++
 2 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 0000c17249..77131130ce 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -149,12 +149,12 @@ class NavigationMixin:
 
 def get_git_log(path):
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
-    command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%ad%n%f'", '--follow', '--', path]
+    command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
     try:
         output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1].split('\n')
     except subprocess.CalledProcessError:
-        output = 5 * ['']
-    return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4]}
+        output = 7 * ['']
+    return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
 
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index e5f209b17e..5426a78644 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -37,6 +37,14 @@
                 <td><span class='fas fa-envelope'></span></td>
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
             </tr>
+            <tr>
+                <td><span class='fas fa-check'></span></td>
+                <td>{% trans "Commit verified" %}</td><td>{{ plugin.commit.verified }}</td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-key'></span></td>
+                <td>{% trans "Commit Sign Key" %}</td><td>{{ plugin.commit.key }}{% include "clip.html" %}</td>
+            </tr>
         </table>
     </div>
     <div class="col-md-6">

From 0bbe1f7687be65391f734cf6c00e512b851a997f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 00:59:33 +0200
Subject: [PATCH 065/493] show sign state with colors

---
 InvenTree/plugins/integration/integration.py             | 1 +
 .../templates/InvenTree/settings/plugin_settings.html    | 9 +++++++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 77131130ce..3fbdf2b015 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -165,6 +165,7 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     def __init__(self):
         self.add_mixin('base')
         self.commit = self.get_plugin_commit()
+        self.sign_state = 0
 
     def mixin(self, key):
         return key in self._mixins
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 5426a78644..633b41a000 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -37,12 +37,17 @@
                 <td><span class='fas fa-envelope'></span></td>
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
             </tr>
+            {% if plugin.sign_state == 0 %}
+            {% define 'text-success' as sign_color %}
+            {% else %}
+            {% define 'text-danger' as sign_color %}
+            {% endif %}
             <tr>
-                <td><span class='fas fa-check'></span></td>
+                <td><span class='{{sign_color}} fas fa-check'></span></td>
                 <td>{% trans "Commit verified" %}</td><td>{{ plugin.commit.verified }}</td>
             </tr>
             <tr>
-                <td><span class='fas fa-key'></span></td>
+                <td><span class='{{sign_color}} fas fa-key'></span></td>
                 <td>{% trans "Commit Sign Key" %}</td><td>{{ plugin.commit.key }}{% include "clip.html" %}</td>
             </tr>
         </table>

From 3ac3004cd0c9fc092f8bd6e20a206443d1a67bf9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 01:03:52 +0200
Subject: [PATCH 066/493] clearer signing communication

---
 .../InvenTree/settings/plugin_settings.html        | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 633b41a000..7ded0b4a08 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -38,17 +38,19 @@
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
             </tr>
             {% if plugin.sign_state == 0 %}
-            {% define 'text-success' as sign_color %}
+            {% define 'success' as sign_color %}
             {% else %}
-            {% define 'text-danger' as sign_color %}
+            {% define 'danger' as sign_color %}
             {% endif %}
             <tr>
-                <td><span class='{{sign_color}} fas fa-check'></span></td>
-                <td>{% trans "Commit verified" %}</td><td>{{ plugin.commit.verified }}</td>
+                <td><span class='text-{{sign_color}} fas fa-check'></span></td>
+                <td>{% trans "Commit verified" %}</td>
+                <td class="bg-{{sign_color}}">{{ plugin.commit.verified }}</td>
             </tr>
             <tr>
-                <td><span class='{{sign_color}} fas fa-key'></span></td>
-                <td>{% trans "Commit Sign Key" %}</td><td>{{ plugin.commit.key }}{% include "clip.html" %}</td>
+                <td><span class='text-{{sign_color}} fas fa-key'></span></td>
+                <td>{% trans "Commit Sign Key" %}</td>
+                <td class="bg-{{sign_color}}">{{ plugin.commit.key }}{% include "clip.html" %}</td>
             </tr>
         </table>
     </div>

From 261537dc43c087cf760dde67a39234fd743e98cc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 01:13:40 +0200
Subject: [PATCH 067/493] refactor sign_color to property

---
 InvenTree/plugins/integration/integration.py        | 12 ++++++++++++
 .../InvenTree/settings/plugin_settings.html         | 13 ++++---------
 2 files changed, 16 insertions(+), 9 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 3fbdf2b015..8a14379c5d 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -164,8 +164,10 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
 
     def __init__(self):
         self.add_mixin('base')
+
         self.commit = self.get_plugin_commit()
         self.sign_state = 0
+        self.set_sign_values()
 
     def mixin(self, key):
         return key in self._mixins
@@ -179,3 +181,13 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     def get_plugin_commit(self):
         path = inspect.getfile(self.__class__)
         return get_git_log(path)
+
+    def set_sign_values(self):
+        if self.sign_state == 0:
+            self.sign_color = 'success'
+
+        elif self.sign_state == 1:
+            self.sign_color = 'warning'
+
+        else:
+            self.sign_color = 'danger'
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 7ded0b4a08..803090d63f 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -37,20 +37,15 @@
                 <td><span class='fas fa-envelope'></span></td>
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
             </tr>
-            {% if plugin.sign_state == 0 %}
-            {% define 'success' as sign_color %}
-            {% else %}
-            {% define 'danger' as sign_color %}
-            {% endif %}
             <tr>
-                <td><span class='text-{{sign_color}} fas fa-check'></span></td>
+                <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
                 <td>{% trans "Commit verified" %}</td>
-                <td class="bg-{{sign_color}}">{{ plugin.commit.verified }}</td>
+                <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.verified }}</td>
             </tr>
             <tr>
-                <td><span class='text-{{sign_color}} fas fa-key'></span></td>
+                <td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td>
                 <td>{% trans "Commit Sign Key" %}</td>
-                <td class="bg-{{sign_color}}">{{ plugin.commit.key }}{% include "clip.html" %}</td>
+                <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.key }}{% include "clip.html" %}</td>
             </tr>
         </table>
     </div>

From 4a0dd72c3d97a86a659bc2bc6000c7701f92f63e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 01:16:30 +0200
Subject: [PATCH 068/493] clearer status language

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 803090d63f..e10f61fff1 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -39,7 +39,7 @@
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
-                <td>{% trans "Commit verified" %}</td>
+                <td>{% trans "Commit Sign Status" %}</td>
                 <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.verified }}</td>
             </tr>
             <tr>

From 8ee565c86aee737f7cf26c706bdc9643833edf22 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 23:24:33 +0200
Subject: [PATCH 069/493] show signing state in settings navbar

---
 InvenTree/templates/InvenTree/settings/navbar.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html
index 512c397292..5e2a5a1811 100644
--- a/InvenTree/templates/InvenTree/settings/navbar.html
+++ b/InvenTree/templates/InvenTree/settings/navbar.html
@@ -132,7 +132,7 @@
     {% for plugin_key, plugin in pl_list.items %}
         {% if plugin.registered_mixins %}
             <li class='list-group-item' title='{{ plugin.plugin_name }}'>
-                <a href='#' class='nav-toggle' id='select-plugin-{{plugin_key}}'>
+                <a href='#' class='text-{{plugin.sign_color}} nav-toggle' id='select-plugin-{{plugin_key}}'>
                     {{ plugin.plugin_name}}
                 </a>
             </li>

From 8285bfe1a33f7e178fa1db0a75d3ee05eb2ecf80 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 23:30:11 +0200
Subject: [PATCH 070/493] general class for git state

---
 InvenTree/plugins/integration/integration.py | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 8a14379c5d..afb860b31e 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -156,6 +156,20 @@ def get_git_log(path):
         output = 7 * ['']
     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
 
+class GitStatus:
+    class definition:
+        key: str = 'N'
+        status: int = 0
+        msg_sign: str = ''
+        msg_key: str = ''
+
+        def __init__(self, key: str='N', status: int = 0, msg_sign: str = '', msg_key: str = '') -> None:
+            self.key = key
+            self.status = status
+            self.msg_sign = msg_sign
+            self.msg_key = msg_key
+
+    E = definition(key='N', status=1, msg_sign='no signature',)
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """

From 24421ee814f266a19a96159e74cb28d18b91571a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 23 Sep 2021 23:52:19 +0200
Subject: [PATCH 071/493] refactor of sign state set function

---
 InvenTree/plugins/integration/integration.py | 17 +++++++++++------
 1 file changed, 11 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index afb860b31e..abb9e7b0e3 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -179,8 +179,6 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     def __init__(self):
         self.add_mixin('base')
 
-        self.commit = self.get_plugin_commit()
-        self.sign_state = 0
         self.set_sign_values()
 
     def mixin(self, key):
@@ -197,11 +195,18 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
         return get_git_log(path)
 
     def set_sign_values(self):
-        if self.sign_state == 0:
+        # fetch git log
+        commit = self.get_plugin_commit()
+        # resolve state
+        sign_state = getattr(GitStatus, commit['verified'], GitStatus.E)
+
+        # set variables
+        self.commit = commit
+        self.sign_state = sign_state
+
+        if sign_state.status == 0:
             self.sign_color = 'success'
-
-        elif self.sign_state == 1:
+        elif sign_state.status == 1:
             self.sign_color = 'warning'
-
         else:
             self.sign_color = 'danger'

From 62702069a077f33865e3feebf4ac811615c627e8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 24 Sep 2021 00:11:19 +0200
Subject: [PATCH 072/493] added sign key

---
 InvenTree/plugins/integration/integration.py | 16 ++++++++++++----
 1 file changed, 12 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index abb9e7b0e3..f51466cced 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -159,17 +159,25 @@ def get_git_log(path):
 class GitStatus:
     class definition:
         key: str = 'N'
-        status: int = 0
+        status: int = 2
         msg_sign: str = ''
         msg_key: str = ''
 
-        def __init__(self, key: str='N', status: int = 0, msg_sign: str = '', msg_key: str = '') -> None:
+        def __init__(self, key: str='N', status: int = 2, msg_sign: str = '', msg_key: str = '') -> None:
             self.key = key
             self.status = status
             self.msg_sign = msg_sign
             self.msg_key = msg_key
 
-    E = definition(key='N', status=1, msg_sign='no signature',)
+    N = definition(key='N', status=2, msg_sign='no signature',)
+    G = definition(key='G', status=0, msg_sign='valid signature',)
+    B = definition(key='B', status=2, msg_sign='bad signature',)
+    U = definition(key='U', status=1, msg_sign='good signature, unknown validity',)
+    X = definition(key='X', status=1, msg_sign='good signature, expired',)
+    Y = definition(key='Y', status=1, msg_sign='good signature, expired key',)
+    R = definition(key='R', status=2, msg_sign='good signature, revoked key',)
+    E = definition(key='E', status=1, msg_sign='cannot be checked',)
+
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """
@@ -198,7 +206,7 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
         # fetch git log
         commit = self.get_plugin_commit()
         # resolve state
-        sign_state = getattr(GitStatus, commit['verified'], GitStatus.E)
+        sign_state = getattr(GitStatus, commit['verified'], GitStatus.N)
 
         # set variables
         self.commit = commit

From 95480559f53a1c63cb1a5551365047137699f984 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 24 Sep 2021 00:17:48 +0200
Subject: [PATCH 073/493] PEP fix

---
 InvenTree/plugins/integration/integration.py | 21 ++++++++++----------
 1 file changed, 11 insertions(+), 10 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index f51466cced..916947d768 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -156,27 +156,28 @@ def get_git_log(path):
         output = 7 * ['']
     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
 
+
 class GitStatus:
-    class definition:
+    class Definition:
         key: str = 'N'
         status: int = 2
         msg_sign: str = ''
         msg_key: str = ''
 
-        def __init__(self, key: str='N', status: int = 2, msg_sign: str = '', msg_key: str = '') -> None:
+        def __init__(self, key: str = 'N', status: int = 2, msg_sign: str = '', msg_key: str = '') -> None:
             self.key = key
             self.status = status
             self.msg_sign = msg_sign
             self.msg_key = msg_key
 
-    N = definition(key='N', status=2, msg_sign='no signature',)
-    G = definition(key='G', status=0, msg_sign='valid signature',)
-    B = definition(key='B', status=2, msg_sign='bad signature',)
-    U = definition(key='U', status=1, msg_sign='good signature, unknown validity',)
-    X = definition(key='X', status=1, msg_sign='good signature, expired',)
-    Y = definition(key='Y', status=1, msg_sign='good signature, expired key',)
-    R = definition(key='R', status=2, msg_sign='good signature, revoked key',)
-    E = definition(key='E', status=1, msg_sign='cannot be checked',)
+    N = Definition(key='N', status=2, msg_sign='no signature',)
+    G = Definition(key='G', status=0, msg_sign='valid signature',)
+    B = Definition(key='B', status=2, msg_sign='bad signature',)
+    U = Definition(key='U', status=1, msg_sign='good signature, unknown validity',)
+    X = Definition(key='X', status=1, msg_sign='good signature, expired',)
+    Y = Definition(key='Y', status=1, msg_sign='good signature, expired key',)
+    R = Definition(key='R', status=2, msg_sign='good signature, revoked key',)
+    E = Definition(key='E', status=1, msg_sign='cannot be checked',)
 
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):

From bb2a35f2d62e790cc52d15729137f3f0206e19e8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 00:06:40 +0200
Subject: [PATCH 074/493] name refactor

---
 InvenTree/plugins/integration/integration.py | 24 +++++++++-----------
 1 file changed, 11 insertions(+), 13 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 916947d768..4a0aafc8ff 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -161,23 +161,21 @@ class GitStatus:
     class Definition:
         key: str = 'N'
         status: int = 2
-        msg_sign: str = ''
-        msg_key: str = ''
+        msg: str = ''
 
-        def __init__(self, key: str = 'N', status: int = 2, msg_sign: str = '', msg_key: str = '') -> None:
+        def __init__(self, key: str = 'N', status: int = 2, msg: str = '') -> None:
             self.key = key
             self.status = status
-            self.msg_sign = msg_sign
-            self.msg_key = msg_key
+            self.msg = msg
 
-    N = Definition(key='N', status=2, msg_sign='no signature',)
-    G = Definition(key='G', status=0, msg_sign='valid signature',)
-    B = Definition(key='B', status=2, msg_sign='bad signature',)
-    U = Definition(key='U', status=1, msg_sign='good signature, unknown validity',)
-    X = Definition(key='X', status=1, msg_sign='good signature, expired',)
-    Y = Definition(key='Y', status=1, msg_sign='good signature, expired key',)
-    R = Definition(key='R', status=2, msg_sign='good signature, revoked key',)
-    E = Definition(key='E', status=1, msg_sign='cannot be checked',)
+    N = Definition(key='N', status=2, msg='no signature',)
+    G = Definition(key='G', status=0, msg='valid signature',)
+    B = Definition(key='B', status=2, msg='bad signature',)
+    U = Definition(key='U', status=1, msg='good signature, unknown validity',)
+    X = Definition(key='X', status=1, msg='good signature, expired',)
+    Y = Definition(key='Y', status=1, msg='good signature, expired key',)
+    R = Definition(key='R', status=2, msg='good signature, revoked key',)
+    E = Definition(key='E', status=1, msg='cannot be checked',)
 
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):

From 70edb330ea3c8ffeea9308e7796f52e83d40c205 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 00:07:27 +0200
Subject: [PATCH 075/493] show sign state

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index e10f61fff1..2769093365 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -40,7 +40,7 @@
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
                 <td>{% trans "Commit Sign Status" %}</td>
-                <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.verified }}</td>
+                <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.verified }}: {{ plugin.sign_state.msg }}</td>
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td>

From 5a2c2b96ec2ad034531e1517c10f501301f457ca Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 00:55:22 +0200
Subject: [PATCH 076/493] starting unittests for plugins

---
 InvenTree/plugins/test_plugin.py | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)
 create mode 100644 InvenTree/plugins/test_plugin.py

diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugins/test_plugin.py
new file mode 100644
index 0000000000..ce03ab686e
--- /dev/null
+++ b/InvenTree/plugins/test_plugin.py
@@ -0,0 +1,24 @@
+""" Unit tests for plugins """
+
+from django.test import TestCase
+
+import plugins.plugin
+
+class InvenTreePluginTests(TestCase):
+    """ Tests for InvenTreePlugin """
+    def setUp(self):
+        self.plugin = plugins.plugin.InvenTreePlugin()
+
+        class NamedPlugin(plugins.plugin.InvenTreePlugin):
+            PLUGIN_NAME = 'abc123'
+
+        self.named_plugin = NamedPlugin()
+
+    def test_basic_plugin_init(self):
+        self.assertEqual(self.plugin.PLUGIN_NAME, '')
+        self.assertEqual(self.plugin.plugin_name(), '')
+
+    def test_basic_plugin_name(self):
+        self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
+        self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
+

From a91e896b209f34f5794467186aa7287de0d1f775 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 00:56:03 +0200
Subject: [PATCH 077/493] move sample code into own file

---
 InvenTree/plugins/action/action.py            | 22 -----------------
 .../plugins/action/simpleactionplugin.py      | 24 +++++++++++++++++++
 2 files changed, 24 insertions(+), 22 deletions(-)
 create mode 100644 InvenTree/plugins/action/simpleactionplugin.py

diff --git a/InvenTree/plugins/action/action.py b/InvenTree/plugins/action/action.py
index 8b9ed6aec9..16e63a3a5c 100644
--- a/InvenTree/plugins/action/action.py
+++ b/InvenTree/plugins/action/action.py
@@ -68,25 +68,3 @@ class ActionPlugin(plugin.InvenTreePlugin):
             "result": self.get_result(),
             "info": self.get_info(),
         }
-
-
-class SimpleActionPlugin(ActionPlugin):
-    """
-    An EXTREMELY simple action plugin which demonstrates
-    the capability of the ActionPlugin class
-    """
-
-    PLUGIN_NAME = "SimpleActionPlugin"
-    ACTION_NAME = "simple"
-
-    def perform_action(self):
-        print("Action plugin in action!")
-
-    def get_info(self):
-        return {
-            "user": self.user.username,
-            "hello": "world",
-        }
-
-    def get_result(self):
-        return True
diff --git a/InvenTree/plugins/action/simpleactionplugin.py b/InvenTree/plugins/action/simpleactionplugin.py
new file mode 100644
index 0000000000..95a50ddbca
--- /dev/null
+++ b/InvenTree/plugins/action/simpleactionplugin.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from plugins.action.action import ActionPlugin
+
+
+class SimpleActionPlugin(ActionPlugin):
+    """
+    An EXTREMELY simple action plugin which demonstrates
+    the capability of the ActionPlugin class
+    """
+
+    PLUGIN_NAME = "SimpleActionPlugin"
+    ACTION_NAME = "simple"
+
+    def perform_action(self):
+        print("Action plugin in action!")
+
+    def get_info(self):
+        return {
+            "user": self.user.username,
+            "hello": "world",
+        }
+
+    def get_result(self):
+        return True

From bbbf9be883e2d4154e14a11a9ca4cda77bad3fa4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 00:56:23 +0200
Subject: [PATCH 078/493] fix docstring

---
 InvenTree/plugins/plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/plugin.py b/InvenTree/plugins/plugin.py
index 11de4d1365..de728ec873 100644
--- a/InvenTree/plugins/plugin.py
+++ b/InvenTree/plugins/plugin.py
@@ -3,7 +3,7 @@
 
 class InvenTreePlugin():
     """
-    Base class for a Barcode plugin
+    Base class for a plugin
     """
 
     # Override the plugin name for each concrete plugin instance

From 1735514364097d7bfbf7808778c9cc5af5465a38 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:22:48 +0200
Subject: [PATCH 079/493] more docstrings!

---
 InvenTree/plugins/action/action.py            |  1 +
 .../plugins/action/simpleactionplugin.py      |  1 +
 .../plugins/integration/another_sample.py     |  1 +
 InvenTree/plugins/integration/integration.py  | 22 ++++++++++++++++++-
 InvenTree/plugins/integration/sample.py       |  2 ++
 InvenTree/plugins/plugin.py                   |  2 ++
 InvenTree/plugins/plugins.py                  |  7 +++---
 InvenTree/plugins/test_plugin.py              |  3 +++
 8 files changed, 35 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugins/action/action.py b/InvenTree/plugins/action/action.py
index 16e63a3a5c..72b24b1a14 100644
--- a/InvenTree/plugins/action/action.py
+++ b/InvenTree/plugins/action/action.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+"""Class for ActionPlugin"""
 
 import logging
 
diff --git a/InvenTree/plugins/action/simpleactionplugin.py b/InvenTree/plugins/action/simpleactionplugin.py
index 95a50ddbca..07ac81f6a6 100644
--- a/InvenTree/plugins/action/simpleactionplugin.py
+++ b/InvenTree/plugins/action/simpleactionplugin.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+"""sample implementation for ActionPlugin"""
 from plugins.action.action import ActionPlugin
 
 
diff --git a/InvenTree/plugins/integration/another_sample.py b/InvenTree/plugins/integration/another_sample.py
index a52fd6d32f..0dd2eb9996 100644
--- a/InvenTree/plugins/integration/another_sample.py
+++ b/InvenTree/plugins/integration/another_sample.py
@@ -1,3 +1,4 @@
+"""sample implementation for IntegrationPlugin"""
 from plugins.integration.integration import IntegrationPlugin, UrlsMixin
 
 
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 4a0aafc8ff..f7f40ec508 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+"""class for IntegrationPlugin and Mixins for it"""
 
 import logging
 import os
@@ -18,12 +19,14 @@ class MixinBase:
     """general base for mixins"""
 
     def add_mixin(self, key: str, fnc_enabled=True, cls=None):
+        """add a mixin to the plugins registry"""
         if not hasattr(self, '_mixins'):
             self._mixins = {}
         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"""
         if not hasattr(self, '_mixinreg'):
             self._mixinreg = {}
 
@@ -38,6 +41,7 @@ class MixinBase:
 
     @property
     def registered_mixins(self, with_base: bool = False):
+        """get all registered mixins for the plugin"""
         mixins = getattr(self, '_mixinreg', None)
         if mixins:
             # filter out base
@@ -52,6 +56,7 @@ class MixinBase:
 class SettingsMixin:
     """Mixin that enables settings for the plugin"""
     class Meta:
+        """meta options for this mixin"""
         MIXIN_NAME = 'Settings'
 
     def __init__(self):
@@ -74,6 +79,9 @@ class SettingsMixin:
 
     @property
     def settingspatterns(self):
+        """
+        get patterns for InvenTreeSetting defintion
+        """
         if self.has_settings:
             return {f'PLUGIN_{self.plugin_name().upper()}_{key}': value for key, value in self.settings.items()}
         return None
@@ -82,6 +90,7 @@ class SettingsMixin:
 class UrlsMixin:
     """Mixin that enables urls for the plugin"""
     class Meta:
+        """meta options for this mixin"""
         MIXIN_NAME = 'URLs'
 
     def __init__(self):
@@ -97,12 +106,15 @@ class UrlsMixin:
 
     @property
     def base_url(self):
+        """
+        returns base url for this plugin
+        """
         return f'{settings.PLUGIN_URL}/{self.plugin_name()}/'
 
     @property
     def urlpatterns(self):
         """
-        retruns the urlpatterns for this plugin
+        returns the urlpatterns for this plugin
         """
         if self.has_urls:
             return url(f'^{self.plugin_name()}/', include((self.urls, self.plugin_name())), name=self.plugin_name())
@@ -119,6 +131,7 @@ class UrlsMixin:
 class NavigationMixin:
     """Mixin that enables adding navigation links with the plugin"""
     class Meta:
+        """meta options for this mixin"""
         MIXIN_NAME = 'Navigation Links'
 
     def __init__(self):
@@ -148,6 +161,7 @@ class NavigationMixin:
 
 
 def get_git_log(path):
+    """get dict with info of the last commit to file named in path"""
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
     command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
     try:
@@ -158,7 +172,9 @@ def get_git_log(path):
 
 
 class GitStatus:
+    """class for resolving git gpg singing state"""
     class Definition:
+        """definition of a git gpg sing state"""
         key: str = 'N'
         status: int = 2
         msg: str = ''
@@ -189,19 +205,23 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
         self.set_sign_values()
 
     def mixin(self, key):
+        """check if mixin is registered"""
         return key in self._mixins
 
     def mixin_enabled(self, key):
+        """check if mixin is enabled and ready"""
         if self.mixin(key):
             fnc_name = self._mixins.get(key)
             return getattr(self, fnc_name, True)
         return False
 
     def get_plugin_commit(self):
+        """get last git commit for plugin"""
         path = inspect.getfile(self.__class__)
         return get_git_log(path)
 
     def set_sign_values(self):
+        """add the last commit of the plugins class file into plugins context"""
         # fetch git log
         commit = self.get_plugin_commit()
         # resolve state
diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index d4c2ea43de..347e0d9b7b 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -1,3 +1,4 @@
+"""sample implementations for IntegrationPlugin"""
 from plugins.integration.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin
 
 from django.http import HttpResponse
@@ -13,6 +14,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     PLUGIN_NAME = "SampleIntegrationPlugin"
 
     def view_test(self, request):
+        """very basic view"""
         return HttpResponse(f'Hi there {request.user.username} this works')
 
     def setup_urls(self):
diff --git a/InvenTree/plugins/plugin.py b/InvenTree/plugins/plugin.py
index de728ec873..93199df7b7 100644
--- a/InvenTree/plugins/plugin.py
+++ b/InvenTree/plugins/plugin.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+"""Base Class for InvenTree plugins"""
 
 
 class InvenTreePlugin():
@@ -10,6 +11,7 @@ class InvenTreePlugin():
     PLUGIN_NAME = ''
 
     def plugin_name(self):
+        """get plugin name"""
         return self.PLUGIN_NAME
 
     def __init__(self):
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index b06957ae07..440aaa6c39 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+"""general functions for plugin handeling"""
 
 import inspect
 import importlib
@@ -17,17 +18,17 @@ logger = logging.getLogger("inventree")
 
 
 def iter_namespace(pkg):
-
+    """get all modules in a package"""
     return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
 
 
 def get_modules(pkg):
-    # Return all modules in a given package
+    """get all modules in a package"""
     return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
 
 
 def get_classes(module):
-    # Return all classes in a given module
+    """get all classes in a given module"""
     return inspect.getmembers(module, inspect.isclass)
 
 
diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugins/test_plugin.py
index ce03ab686e..db8952c38f 100644
--- a/InvenTree/plugins/test_plugin.py
+++ b/InvenTree/plugins/test_plugin.py
@@ -10,15 +10,18 @@ class InvenTreePluginTests(TestCase):
         self.plugin = plugins.plugin.InvenTreePlugin()
 
         class NamedPlugin(plugins.plugin.InvenTreePlugin):
+            """a named plugin"""
             PLUGIN_NAME = 'abc123'
 
         self.named_plugin = NamedPlugin()
 
     def test_basic_plugin_init(self):
+        """check if a basic plugin intis"""
         self.assertEqual(self.plugin.PLUGIN_NAME, '')
         self.assertEqual(self.plugin.plugin_name(), '')
 
     def test_basic_plugin_name(self):
+        """check if the name of a basic plugin can be set"""
         self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
         self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
 

From d8508cb741d57cd6b52954fd63271af211430bdf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:23:11 +0200
Subject: [PATCH 080/493] make logging PEP compliant

---
 InvenTree/plugins/plugins.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 440aaa6c39..e01b259dfe 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -64,15 +64,15 @@ def load_plugins(name: str, module, cls):
     :return: class of the to-be-loaded plugin
     """
 
-    logger.debug(f"Loading {name} plugins")
+    logger.debug("Loading %s plugins", name)
 
     plugins = get_plugins(module, cls)
 
     if len(plugins) > 0:
-        logger.info(f"Discovered {len(plugins)} {name} plugins:")
+        logger.info("Discovered %i %s plugins:", len(plugins), name)
 
         for ap in plugins:
-            logger.debug(" - {ap}".format(ap=ap.PLUGIN_NAME))
+            logger.debug(" - %s", ap.PLUGIN_NAME)
 
     return plugins
 

From 5b691c90f9db1935308b88f7579a505a5fac8258 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:23:22 +0200
Subject: [PATCH 081/493] not needed

---
 InvenTree/plugins/action/action.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/plugins/action/action.py b/InvenTree/plugins/action/action.py
index 72b24b1a14..cc2872b6d2 100644
--- a/InvenTree/plugins/action/action.py
+++ b/InvenTree/plugins/action/action.py
@@ -43,7 +43,6 @@ class ActionPlugin(plugin.InvenTreePlugin):
         """
         Override this method to perform the action!
         """
-        pass
 
     def get_result(self):
         """

From 06ae9794726fdac0e786fda327b6dd22e4fbb748 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:23:32 +0200
Subject: [PATCH 082/493] always init

---
 InvenTree/plugins/integration/integration.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index f7f40ec508..9a87c00db7 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -200,6 +200,7 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """
 
     def __init__(self):
+        super().__init__()
         self.add_mixin('base')
 
         self.set_sign_values()

From 9436dad54e90cffe5bf1b87ae9c6f04e62896c8d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:23:56 +0200
Subject: [PATCH 083/493] we do not really need this integration sample

---
 InvenTree/plugins/integration/sample.py | 23 -----------------------
 1 file changed, 23 deletions(-)

diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index 347e0d9b7b..232db9f608 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -40,26 +40,3 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     NAVIGATION = [
         {'name': 'SampleIntegration', 'link': 'plugin:SampleIntegrationPlugin:hi'},
     ]
-
-
-class OtherIntegrationPlugin(UrlsMixin, IntegrationPlugin):
-    """
-    An basic integration plugin
-    """
-
-    PLUGIN_NAME = "OtherIntegrationPlugin"
-
-    # @cls_login_required()
-    def view_test(self, request):
-        return HttpResponse(f'Hi there {request.user.username} this works')
-
-    def setup_urls(self):
-        he = [
-            url(r'^he/', self.view_test, name='he'),
-            url(r'^ha/', self.view_test, name='ha'),
-        ]
-
-        return [
-            url(r'^hi/', self.view_test, name='hi'),
-            url(r'^ho/', include(he), name='ho'),
-        ]

From 18694c9b9e008c1a6afb61ffd4c191d709a0d3e3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:27:20 +0200
Subject: [PATCH 084/493] name refactor

---
 InvenTree/plugins/plugins.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index e01b259dfe..79a5a3f40e 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -71,8 +71,8 @@ def load_plugins(name: str, module, cls):
     if len(plugins) > 0:
         logger.info("Discovered %i %s plugins:", len(plugins), name)
 
-        for ap in plugins:
-            logger.debug(" - %s", ap.PLUGIN_NAME)
+        for plugin in plugins:
+            logger.debug(" - %s", plugin.PLUGIN_NAME)
 
     return plugins
 

From b2cb89d488f9c9b113a2a94526a9a74e1d0f9f9c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:29:56 +0200
Subject: [PATCH 085/493] init on startup

---
 InvenTree/plugins/integration/integration.py | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index 9a87c00db7..a3262f1549 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -18,18 +18,17 @@ logger = logging.getLogger("inventree")
 class MixinBase:
     """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"""
-        if not hasattr(self, '_mixins'):
-            self._mixins = {}
         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"""
-        if not hasattr(self, '_mixinreg'):
-            self._mixinreg = {}
-
         # get human name
         human_name = getattr(cls.Meta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'Meta') else key
 

From 91e7c05352674d3186a19fd18c54170463ab8719 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:30:05 +0200
Subject: [PATCH 086/493] refactor name

---
 InvenTree/plugins/integration/sample.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/integration/sample.py
index 232db9f608..8fa2be5ffa 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/integration/sample.py
@@ -18,14 +18,14 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
         return HttpResponse(f'Hi there {request.user.username} this works')
 
     def setup_urls(self):
-        he = [
+        he_urls = [
             url(r'^he/', self.view_test, name='he'),
             url(r'^ha/', self.view_test, name='ha'),
         ]
 
         return [
             url(r'^hi/', self.view_test, name='hi'),
-            url(r'^ho/', include(he), name='ho'),
+            url(r'^ho/', include(he_urls), name='ho'),
         ]
 
     SETTINGS = {

From 41cf7219078d3efe2c91e86e4d618f9c676d4669 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 25 Sep 2021 01:31:08 +0200
Subject: [PATCH 087/493] simpler return definition

---
 InvenTree/plugins/integration/integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration/integration.py
index a3262f1549..ab8e71927c 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration/integration.py
@@ -47,7 +47,7 @@ class MixinBase:
             if not with_base and 'base' in mixins:
                 del mixins['base']
             # only return dict
-            mixins = [a for a in mixins.values()]
+            mixins = mixins.values()
         return mixins
 
 

From 7393834a79a4c14a8ee6b84ee96ac457613fa4b3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 13:29:52 +0200
Subject: [PATCH 088/493] plugin load testing

---
 InvenTree/plugins/test_plugin.py | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugins/test_plugin.py
index db8952c38f..36208dd5a1 100644
--- a/InvenTree/plugins/test_plugin.py
+++ b/InvenTree/plugins/test_plugin.py
@@ -3,6 +3,8 @@
 from django.test import TestCase
 
 import plugins.plugin
+from plugins.plugins import load_action_plugins, load_barcode_plugins, load_integration_plugins
+
 
 class InvenTreePluginTests(TestCase):
     """ Tests for InvenTreePlugin """
@@ -25,3 +27,16 @@ class InvenTreePluginTests(TestCase):
         self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
         self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
 
+
+class PluginIntegrationTests(TestCase):
+    """ Tests for general plugin functions """
+
+    def test_plugin_loading(self):
+        """check if plugins load as expected"""
+        plugin_names_integration = [a().plugin_name() for a in load_integration_plugins()]
+        #plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
+        #plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
+
+        self.assertEqual(plugin_names_integration, ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
+        #self.assertEqual(plugin_names_action, '')
+        #self.assertEqual(plugin_names_barcode, '')

From 7a551bf9d1431cec9dd47999f95fc53fed1b7e9d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 14:47:45 +0200
Subject: [PATCH 089/493] refactor of file structure

---
 InvenTree/plugins/{action => }/action.py                  | 0
 InvenTree/plugins/{integration => }/integration.py        | 1 -
 InvenTree/plugins/plugins.py                              | 8 ++++----
 InvenTree/plugins/{ => samples}/action/__init__.py        | 0
 .../plugins/{ => samples}/action/simpleactionplugin.py    | 0
 InvenTree/plugins/{ => samples}/integration/__init__.py   | 0
 .../plugins/{ => samples}/integration/another_sample.py   | 2 +-
 InvenTree/plugins/{ => samples}/integration/sample.py     | 2 +-
 8 files changed, 6 insertions(+), 7 deletions(-)
 rename InvenTree/plugins/{action => }/action.py (100%)
 rename InvenTree/plugins/{integration => }/integration.py (98%)
 rename InvenTree/plugins/{ => samples}/action/__init__.py (100%)
 rename InvenTree/plugins/{ => samples}/action/simpleactionplugin.py (100%)
 rename InvenTree/plugins/{ => samples}/integration/__init__.py (100%)
 rename InvenTree/plugins/{ => samples}/integration/another_sample.py (82%)
 rename InvenTree/plugins/{ => samples}/integration/sample.py (91%)

diff --git a/InvenTree/plugins/action/action.py b/InvenTree/plugins/action.py
similarity index 100%
rename from InvenTree/plugins/action/action.py
rename to InvenTree/plugins/action.py
diff --git a/InvenTree/plugins/integration/integration.py b/InvenTree/plugins/integration.py
similarity index 98%
rename from InvenTree/plugins/integration/integration.py
rename to InvenTree/plugins/integration.py
index ab8e71927c..974f6fa49e 100644
--- a/InvenTree/plugins/integration/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -164,7 +164,6 @@ def get_git_log(path):
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
     command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
     try:
-        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1].split('\n')
     except subprocess.CalledProcessError:
         output = 7 * ['']
     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 79a5a3f40e..0435a023f4 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -7,11 +7,11 @@ import pkgutil
 import logging
 
 # Action plugins
-import plugins.action as action
-from plugins.action.action import ActionPlugin
+import plugins.samples.action as action
+from plugins.action import ActionPlugin
 
-import plugins.integration as integration
-from plugins.integration.integration import IntegrationPlugin
+import plugins.samples.integration as integration
+from plugins.integration import IntegrationPlugin
 
 
 logger = logging.getLogger("inventree")
diff --git a/InvenTree/plugins/action/__init__.py b/InvenTree/plugins/samples/action/__init__.py
similarity index 100%
rename from InvenTree/plugins/action/__init__.py
rename to InvenTree/plugins/samples/action/__init__.py
diff --git a/InvenTree/plugins/action/simpleactionplugin.py b/InvenTree/plugins/samples/action/simpleactionplugin.py
similarity index 100%
rename from InvenTree/plugins/action/simpleactionplugin.py
rename to InvenTree/plugins/samples/action/simpleactionplugin.py
diff --git a/InvenTree/plugins/integration/__init__.py b/InvenTree/plugins/samples/integration/__init__.py
similarity index 100%
rename from InvenTree/plugins/integration/__init__.py
rename to InvenTree/plugins/samples/integration/__init__.py
diff --git a/InvenTree/plugins/integration/another_sample.py b/InvenTree/plugins/samples/integration/another_sample.py
similarity index 82%
rename from InvenTree/plugins/integration/another_sample.py
rename to InvenTree/plugins/samples/integration/another_sample.py
index 0dd2eb9996..fc3f8d1b23 100644
--- a/InvenTree/plugins/integration/another_sample.py
+++ b/InvenTree/plugins/samples/integration/another_sample.py
@@ -1,5 +1,5 @@
 """sample implementation for IntegrationPlugin"""
-from plugins.integration.integration import IntegrationPlugin, UrlsMixin
+from plugins.integration import IntegrationPlugin, UrlsMixin
 
 
 class NoIntegrationPlugin(IntegrationPlugin):
diff --git a/InvenTree/plugins/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
similarity index 91%
rename from InvenTree/plugins/integration/sample.py
rename to InvenTree/plugins/samples/integration/sample.py
index 8fa2be5ffa..8e1c7c6894 100644
--- a/InvenTree/plugins/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -1,5 +1,5 @@
 """sample implementations for IntegrationPlugin"""
-from plugins.integration.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin
+from plugins.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _

From b39ce3cd50cb688d2a251bf43826da5c532346ca Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 14:55:41 +0200
Subject: [PATCH 090/493] fixing import path

---
 InvenTree/plugins/samples/action/simpleactionplugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/samples/action/simpleactionplugin.py b/InvenTree/plugins/samples/action/simpleactionplugin.py
index 07ac81f6a6..def8aada8b 100644
--- a/InvenTree/plugins/samples/action/simpleactionplugin.py
+++ b/InvenTree/plugins/samples/action/simpleactionplugin.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 """sample implementation for ActionPlugin"""
-from plugins.action.action import ActionPlugin
+from plugins.action import ActionPlugin
 
 
 class SimpleActionPlugin(ActionPlugin):

From 897be4cc1a77f506e47f66e6130c22cee14edbd1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 14:56:38 +0200
Subject: [PATCH 091/493] also work if no commit is present

---
 InvenTree/plugins/integration.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 974f6fa49e..846d1738a4 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -164,6 +164,11 @@ def get_git_log(path):
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
     command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
     try:
+        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
+        if output:
+            output = output.split('\n')
+        else:
+            output = 7 * ['']
     except subprocess.CalledProcessError:
         output = 7 * ['']
     return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}

From a7cba2320bb04d6d8c5fb7486b01f5b74bc38a4c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 15:20:31 +0200
Subject: [PATCH 092/493] style fix

---
 InvenTree/plugins/test_plugin.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugins/test_plugin.py
index 36208dd5a1..12e82c0a2f 100644
--- a/InvenTree/plugins/test_plugin.py
+++ b/InvenTree/plugins/test_plugin.py
@@ -3,7 +3,7 @@
 from django.test import TestCase
 
 import plugins.plugin
-from plugins.plugins import load_action_plugins, load_barcode_plugins, load_integration_plugins
+from plugins.plugins import load_integration_plugins  # , load_action_plugins, load_barcode_plugins
 
 
 class InvenTreePluginTests(TestCase):
@@ -34,9 +34,9 @@ class PluginIntegrationTests(TestCase):
     def test_plugin_loading(self):
         """check if plugins load as expected"""
         plugin_names_integration = [a().plugin_name() for a in load_integration_plugins()]
-        #plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
-        #plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
+        # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
+        # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
         self.assertEqual(plugin_names_integration, ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
-        #self.assertEqual(plugin_names_action, '')
-        #self.assertEqual(plugin_names_barcode, '')
+        # self.assertEqual(plugin_names_action, '')
+        # self.assertEqual(plugin_names_barcode, '')

From 95caecafa3edeafb2321ee5d046df97530663450 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 15:27:24 +0200
Subject: [PATCH 093/493] more regions

---
 InvenTree/plugins/integration.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 846d1738a4..4342f56caa 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -15,6 +15,7 @@ import plugins.plugin as plugin
 logger = logging.getLogger("inventree")
 
 
+# region mixins
 class MixinBase:
     """general base for mixins"""
 
@@ -51,7 +52,6 @@ class MixinBase:
         return mixins
 
 
-# region mixins
 class SettingsMixin:
     """Mixin that enables settings for the plugin"""
     class Meta:
@@ -159,6 +159,7 @@ class NavigationMixin:
 # endregion
 
 
+# region git-helpers
 def get_git_log(path):
     """get dict with info of the last commit to file named in path"""
     path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
@@ -195,7 +196,7 @@ class GitStatus:
     Y = Definition(key='Y', status=1, msg='good signature, expired key',)
     R = Definition(key='R', status=2, msg='good signature, revoked key',)
     E = Definition(key='E', status=1, msg='cannot be checked',)
-
+# endregion
 
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """

From f604837f4d75faf329ed9270358caecd281d2b06 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 15:49:50 +0200
Subject: [PATCH 094/493] tags testing

---
 InvenTree/plugins/test_plugin.py | 32 ++++++++++++++++++++++++++++++++
 1 file changed, 32 insertions(+)

diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugins/test_plugin.py
index 12e82c0a2f..9b17164cd6 100644
--- a/InvenTree/plugins/test_plugin.py
+++ b/InvenTree/plugins/test_plugin.py
@@ -1,9 +1,14 @@
 """ Unit tests for plugins """
 
 from django.test import TestCase
+from django.conf import settings
 
 import plugins.plugin
+import plugins.integration
+from plugins.samples.integration.sample import SampleIntegrationPlugin
+from plugins.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
 from plugins.plugins import load_integration_plugins  # , load_action_plugins, load_barcode_plugins
+import part.templatetags.plugin_extras as plugin_tags
 
 
 class InvenTreePluginTests(TestCase):
@@ -40,3 +45,30 @@ class PluginIntegrationTests(TestCase):
         self.assertEqual(plugin_names_integration, ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
+
+
+class PluginTagTests(TestCase):
+    """ Tests for the plugin extras """
+
+    def setUp(self):
+        self.sample = SampleIntegrationPlugin()
+        self.no = NoIntegrationPlugin()
+        self.wrong = WrongIntegrationPlugin()
+
+    def test_tag_plugin_list(self):
+        """test that all plugins are listed"""
+        self.assertEqual(plugin_tags.plugin_list(), settings.INTEGRATION_PLUGIN_LIST)
+
+    def test_tag_plugin_settings(self):
+        """check all plugins are listed"""
+        self.assertEqual(plugin_tags.plugin_settings(self.sample), settings.INTEGRATION_PLUGIN_SETTING.get(self.sample))
+
+    def test_tag_mixin_enabled(self):
+        """check that mixin enabled functions work"""
+        key = 'urls'
+        # mixin enabled
+        self.assertEqual(plugin_tags.mixin_enabled(self.sample, key), True)
+        # mixin not enabled
+        self.assertEqual(plugin_tags.mixin_enabled(self.wrong, key), False)
+        # mxixn not existing
+        self.assertEqual(plugin_tags.mixin_enabled(self.no, key), False)

From fb94a0d335184c21f198d5b8ca8852f033e733d7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 15:56:49 +0200
Subject: [PATCH 095/493] PEP fix

---
 InvenTree/plugins/integration.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 4342f56caa..ecd016c2b9 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -198,6 +198,7 @@ class GitStatus:
     E = Definition(key='E', status=1, msg='cannot be checked',)
 # endregion
 
+
 class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     """
     The IntegrationPlugin class is used to integrate with 3rd party software

From 64e177a10069af4303581b8773f8dd9edb8017a1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 22:16:37 +0200
Subject: [PATCH 096/493] refactor

---
 InvenTree/plugins/test_plugin.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugins/test_plugin.py
index 9b17164cd6..aa8aa92cfc 100644
--- a/InvenTree/plugins/test_plugin.py
+++ b/InvenTree/plugins/test_plugin.py
@@ -52,8 +52,8 @@ class PluginTagTests(TestCase):
 
     def setUp(self):
         self.sample = SampleIntegrationPlugin()
-        self.no = NoIntegrationPlugin()
-        self.wrong = WrongIntegrationPlugin()
+        self.plugin_no = NoIntegrationPlugin()
+        self.plugin_wrong = WrongIntegrationPlugin()
 
     def test_tag_plugin_list(self):
         """test that all plugins are listed"""
@@ -69,6 +69,6 @@ class PluginTagTests(TestCase):
         # mixin enabled
         self.assertEqual(plugin_tags.mixin_enabled(self.sample, key), True)
         # mixin not enabled
-        self.assertEqual(plugin_tags.mixin_enabled(self.wrong, key), False)
+        self.assertEqual(plugin_tags.mixin_enabled(self.plugin_wrong, key), False)
         # mxixn not existing
-        self.assertEqual(plugin_tags.mixin_enabled(self.no, key), False)
+        self.assertEqual(plugin_tags.mixin_enabled(self.plugin_no, key), False)

From 2ce8d7d4b1b04dfeb1b57ca19ed134b629f17f68 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 26 Sep 2021 22:20:24 +0200
Subject: [PATCH 097/493] removing unneeded double

---
 InvenTree/InvenTree/plugins.py | 43 ----------------------------------
 1 file changed, 43 deletions(-)
 delete mode 100644 InvenTree/InvenTree/plugins.py

diff --git a/InvenTree/InvenTree/plugins.py b/InvenTree/InvenTree/plugins.py
deleted file mode 100644
index 8da725bf9c..0000000000
--- a/InvenTree/InvenTree/plugins.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import inspect
-import importlib
-import pkgutil
-
-
-def iter_namespace(pkg):
-
-    return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
-
-
-def get_modules(pkg):
-    # Return all modules in a given package
-    return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
-
-
-def get_classes(module):
-    # Return all classes in a given module
-    return inspect.getmembers(module, inspect.isclass)
-
-
-def get_plugins(pkg, baseclass):
-    """
-    Return a list of all modules under a given package.
-
-    - Modules must be a subclass of the provided 'baseclass'
-    - Modules must have a non-empty PLUGIN_NAME parameter
-    """
-
-    plugins = []
-
-    modules = get_modules(pkg)
-
-    # Iterate through each module in the package
-    for mod in modules:
-        # Iterate through each class in the module
-        for item in get_classes(mod):
-            plugin = item[1]
-            if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
-                plugins.append(plugin)
-
-    return plugins

From 9d1d8bbeaa8b74b8c8bc911ae7b5b60450bfb441 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 01:09:25 +0200
Subject: [PATCH 098/493] integration tests

---
 InvenTree/plugins/test_integration.py | 84 +++++++++++++++++++++++++++
 1 file changed, 84 insertions(+)
 create mode 100644 InvenTree/plugins/test_integration.py

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
new file mode 100644
index 0000000000..afed87a657
--- /dev/null
+++ b/InvenTree/plugins/test_integration.py
@@ -0,0 +1,84 @@
+""" Unit tests for integration plugins """
+
+from django.test import TestCase
+from django.conf import settings
+from django.conf.urls import url, include
+
+from plugins.integration import IntegrationPlugin, MixinBase, SettingsMixin, UrlsMixin, NavigationMixin
+import part.templatetags.plugin_extras as plugin_tags
+
+
+class BaseMixinDefinition:
+    def test_mixin_name(self):
+        # mixin name
+        self.assertEqual(self.mixin.registered_mixins[0]['key'], self.MIXIN_NAME)
+        # human name
+        self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
+        # mixin check
+        #  self.assertEqual(self.mixin.plugin_name(), self.MIXIN_ENABLE_CHECK)
+
+
+class SettingsMixinTest(BaseMixinDefinition, TestCase):
+    MIXIN_HUMAN_NAME = 'Settings'
+    MIXIN_NAME = 'settings'
+    MIXIN_ENABLE_CHECK = 'has_settings'
+
+    TEST_SETTINGS = {'setting1': [1, 2, 3]}
+
+    def setUp(self):
+        class cls(SettingsMixin, IntegrationPlugin):
+            SETTINGS = self.TEST_SETTINGS
+        self.mixin = cls()
+
+    def test_function(self):
+        # settings variable
+        self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
+        # settings pattern
+        target_pattern = {f'PLUGIN_{self.mixin.plugin_name().upper()}_{key}': value for key, value in self.mixin.settings.items()}
+        self.assertEqual(self.mixin.settingspatterns, target_pattern)
+
+
+class UrlsMixinTest(BaseMixinDefinition, TestCase):
+    MIXIN_HUMAN_NAME = 'URLs'
+    MIXIN_NAME = 'urls'
+    MIXIN_ENABLE_CHECK = 'has_urls'
+
+    def setUp(self):
+        class cls(UrlsMixin, IntegrationPlugin):
+            def test(self):
+                return 'ccc'
+            URLS = [url('test', test, name='test'),]
+        self.mixin = cls()
+
+    def test_function(self):
+        plg_name = self.mixin.plugin_name()
+
+        # base_url
+        target_url = f'{settings.PLUGIN_URL}/{plg_name}/'
+        self.assertEqual(self.mixin.base_url, target_url)
+
+        # urlpattern
+        target_pattern = url(f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name)
+        self.assertEqual(self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict)
+
+
+class NavigationMixinTest(BaseMixinDefinition, TestCase):
+    MIXIN_HUMAN_NAME = 'Navigation Links'
+    MIXIN_NAME = 'navigation'
+    MIXIN_ENABLE_CHECK = 'has_naviation'
+
+    def setUp(self):
+        class cls(NavigationMixin, IntegrationPlugin):
+            NAVIGATION = [
+                {'name': 'aa', 'link': 'plugin:test:test_view'},
+            ]
+        self.mixin = cls()
+
+    def test_function(self):
+        # check right configuration
+        self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'},])
+        # check wrong links fails
+        with self.assertRaises(NotImplementedError):
+            class cls(NavigationMixin, IntegrationPlugin):
+                NAVIGATION = ['aa', 'aa']
+            cls()

From 7040eda11ec7fa2aab7ce80dc7b94147b0880259 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 01:16:10 +0200
Subject: [PATCH 099/493] PEP fix

---
 InvenTree/plugins/test_integration.py | 23 +++++++++++------------
 1 file changed, 11 insertions(+), 12 deletions(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index afed87a657..cbb1e97d6e 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,8 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPlugin, MixinBase, SettingsMixin, UrlsMixin, NavigationMixin
-import part.templatetags.plugin_extras as plugin_tags
+from plugins.integration import IntegrationPlugin, SettingsMixin, UrlsMixin, NavigationMixin  # MixinBase
 
 
 class BaseMixinDefinition:
@@ -26,9 +25,9 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
     TEST_SETTINGS = {'setting1': [1, 2, 3]}
 
     def setUp(self):
-        class cls(SettingsMixin, IntegrationPlugin):
+        class SettingsCls(SettingsMixin, IntegrationPlugin):
             SETTINGS = self.TEST_SETTINGS
-        self.mixin = cls()
+        self.mixin = SettingsCls()
 
     def test_function(self):
         # settings variable
@@ -44,11 +43,11 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_ENABLE_CHECK = 'has_urls'
 
     def setUp(self):
-        class cls(UrlsMixin, IntegrationPlugin):
+        class UrlsCls(UrlsMixin, IntegrationPlugin):
             def test(self):
                 return 'ccc'
-            URLS = [url('test', test, name='test'),]
-        self.mixin = cls()
+            URLS = [url('test', test, name='test'), ]
+        self.mixin = UrlsCls()
 
     def test_function(self):
         plg_name = self.mixin.plugin_name()
@@ -68,17 +67,17 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_ENABLE_CHECK = 'has_naviation'
 
     def setUp(self):
-        class cls(NavigationMixin, IntegrationPlugin):
+        class NavigationCls(NavigationMixin, IntegrationPlugin):
             NAVIGATION = [
-                {'name': 'aa', 'link': 'plugin:test:test_view'},
+                {'name': 'aa', 'link': 'plugin:test:test_view'}, 
             ]
-        self.mixin = cls()
+        self.mixin = NavigationCls()
 
     def test_function(self):
         # check right configuration
         self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'},])
         # check wrong links fails
         with self.assertRaises(NotImplementedError):
-            class cls(NavigationMixin, IntegrationPlugin):
+            class NavigationCls(NavigationMixin, IntegrationPlugin):
                 NAVIGATION = ['aa', 'aa']
-            cls()
+            NavigationCls()

From 7e9f22cbde340ff2fd310bd9ddf8449ebec65bd8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 01:19:51 +0200
Subject: [PATCH 100/493] PEP fix 2

---
 InvenTree/plugins/test_integration.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index cbb1e97d6e..e06114bb01 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -69,13 +69,13 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
     def setUp(self):
         class NavigationCls(NavigationMixin, IntegrationPlugin):
             NAVIGATION = [
-                {'name': 'aa', 'link': 'plugin:test:test_view'}, 
+                {'name': 'aa', 'link': 'plugin:test:test_view'},
             ]
         self.mixin = NavigationCls()
 
     def test_function(self):
         # check right configuration
-        self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'},])
+        self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
         # check wrong links fails
         with self.assertRaises(NotImplementedError):
             class NavigationCls(NavigationMixin, IntegrationPlugin):

From e41e2586a65334c3da38321329173c1480fcec51 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 01:26:04 +0200
Subject: [PATCH 101/493] fix values return

---
 InvenTree/plugins/integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index ecd016c2b9..69ec3d22a3 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -48,7 +48,7 @@ class MixinBase:
             if not with_base and 'base' in mixins:
                 del mixins['base']
             # only return dict
-            mixins = mixins.values()
+            mixins = [a for a in mixins.values()]
         return mixins
 
 

From c94e1347d3f123d41fb9d861f7315fea94c16e8c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 22:28:39 +0200
Subject: [PATCH 102/493] more coverage

---
 InvenTree/plugins/test_integration.py | 19 +++++++++++++++++++
 1 file changed, 19 insertions(+)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index e06114bb01..cef3f8af0c 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -28,14 +28,23 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         class SettingsCls(SettingsMixin, IntegrationPlugin):
             SETTINGS = self.TEST_SETTINGS
         self.mixin = SettingsCls()
+        class NoSettingsCls(SettingsMixin, IntegrationPlugin):
+            pass
+        self.mixin_nothing = NoSettingsCls()
 
     def test_function(self):
         # settings variable
         self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
+
         # settings pattern
         target_pattern = {f'PLUGIN_{self.mixin.plugin_name().upper()}_{key}': value for key, value in self.mixin.settings.items()}
         self.assertEqual(self.mixin.settingspatterns, target_pattern)
 
+        # no settings
+        self.assertIsNone(self.mixin_nothing.settings)
+        self.assertIsNone(self.mixin_nothing.settingspatterns)
+
+
 
 class UrlsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'URLs'
@@ -48,6 +57,9 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
                 return 'ccc'
             URLS = [url('test', test, name='test'), ]
         self.mixin = UrlsCls()
+        class NoUrlsCls(UrlsMixin, IntegrationPlugin):
+            pass
+        self.mixin_nothing = NoUrlsCls()
 
     def test_function(self):
         plg_name = self.mixin.plugin_name()
@@ -60,6 +72,13 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
         target_pattern = url(f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name)
         self.assertEqual(self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict)
 
+        # resolve the view
+        self.assertEqual(self.mixin.urlpatterns, 'ccc')
+
+        # no url
+        self.assertIsNone(self.mixin_nothing.urls)
+        self.assertIsNone(self.mixin_nothing.urlpatterns)
+
 
 class NavigationMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'Navigation Links'

From 1f96885e05b281a7177d7fe5396a0936113cdd25 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 22:36:04 +0200
Subject: [PATCH 103/493] check url resolver

---
 InvenTree/plugins/test_integration.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index cef3f8af0c..b8b5b15a33 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -53,9 +53,9 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
 
     def setUp(self):
         class UrlsCls(UrlsMixin, IntegrationPlugin):
-            def test(self):
+            def test():
                 return 'ccc'
-            URLS = [url('test', test, name='test'), ]
+            URLS = [url('testpath', test, name='test'), ]
         self.mixin = UrlsCls()
         class NoUrlsCls(UrlsMixin, IntegrationPlugin):
             pass
@@ -73,7 +73,8 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
         self.assertEqual(self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict)
 
         # resolve the view
-        self.assertEqual(self.mixin.urlpatterns, 'ccc')
+        self.assertEqual(self.mixin.urlpatterns.resolve('/testpath').func(), 'ccc')
+        self.assertEqual(self.mixin.urlpatterns.reverse('test'), 'testpath')
 
         # no url
         self.assertIsNone(self.mixin_nothing.urls)

From ed312b9cf13b8288d99fb94a3c013252c046ae2a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 22:37:54 +0200
Subject: [PATCH 104/493] PEP fix

---
 InvenTree/plugins/test_integration.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index b8b5b15a33..8055252a92 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -28,6 +28,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         class SettingsCls(SettingsMixin, IntegrationPlugin):
             SETTINGS = self.TEST_SETTINGS
         self.mixin = SettingsCls()
+
         class NoSettingsCls(SettingsMixin, IntegrationPlugin):
             pass
         self.mixin_nothing = NoSettingsCls()
@@ -45,7 +46,6 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         self.assertIsNone(self.mixin_nothing.settingspatterns)
 
 
-
 class UrlsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'URLs'
     MIXIN_NAME = 'urls'
@@ -57,6 +57,7 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
                 return 'ccc'
             URLS = [url('testpath', test, name='test'), ]
         self.mixin = UrlsCls()
+
         class NoUrlsCls(UrlsMixin, IntegrationPlugin):
             pass
         self.mixin_nothing = NoUrlsCls()

From ae66442156cf40425f523ec1d052ba3bc05aafd5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 22:55:52 +0200
Subject: [PATCH 105/493] test for action plugin

---
 InvenTree/plugins/test_action.py | 61 ++++++++++++++++++++++++++++++++
 1 file changed, 61 insertions(+)
 create mode 100644 InvenTree/plugins/test_action.py

diff --git a/InvenTree/plugins/test_action.py b/InvenTree/plugins/test_action.py
new file mode 100644
index 0000000000..5350b31bfa
--- /dev/null
+++ b/InvenTree/plugins/test_action.py
@@ -0,0 +1,61 @@
+""" Unit tests for action plugins """
+
+from django.test import TestCase
+
+from plugins.action import ActionPlugin
+
+
+class ActionPluginTests(TestCase):
+    """ Tests for ActionPlugin """
+    ACTION_RETURN = 'a action was performed'
+
+    def setUp(self):
+        self.plugin = ActionPlugin('user')
+
+        class TestActionPlugin(ActionPlugin):
+            """a action plugin"""
+            ACTION_NAME = 'abc123'
+
+            def perform_action():
+                return self.ACTION_RETURN + 'action'
+
+            def get_result():
+                return self.ACTION_RETURN + 'result'
+
+            def get_info():
+                return self.ACTION_RETURN + 'info'
+
+        self.action_plugin = TestActionPlugin('user')
+
+        class NameActionPlugin(ActionPlugin):
+            PLUGIN_NAME = 'Aplugin'
+
+        self.action_name = NameActionPlugin('user')
+
+    def test_action_name(self):
+        """check the name definition possibilities"""
+        self.assertEqual(self.plugin.plugin_name(), '')
+        self.assertEqual(self.action_plugin.plugin_name(), 'abc123')
+        self.assertEqual(self.action_name.plugin_name(), 'Aplugin')
+
+    def test_function(self):
+        """check functions"""
+        # the class itself
+        self.assertIsNone(self.plugin.perform_action())
+        self.assertEqual(self.plugin.get_result(), False)
+        self.assertIsNone(self.plugin.get_info())
+        self.assertEqual(self.plugin.get_response(), {
+            "action": '',
+            "result": False,
+            "info": None,
+        })
+
+        # overriden functions
+        self.assertEqual(self.plugin.perform_action(), self.ACTION_RETURN + 'action')
+        self.assertEqual(self.plugin.get_result(), self.ACTION_RETURN + 'result')
+        self.assertIsNone(self.plugin.get_info(), self.ACTION_RETURN + 'info')
+        self.assertEqual(self.plugin.get_response(), {
+            "action": 'abc123',
+            "result": self.ACTION_RETURN + 'result',
+            "info": self.ACTION_RETURN + 'info',
+        })

From cd9d3cdcb6dee976957401d1e95c1bb5aa97d346 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 28 Sep 2021 23:45:32 +0200
Subject: [PATCH 106/493] fix names

---
 InvenTree/plugins/test_action.py | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/InvenTree/plugins/test_action.py b/InvenTree/plugins/test_action.py
index 5350b31bfa..7b539cbfcc 100644
--- a/InvenTree/plugins/test_action.py
+++ b/InvenTree/plugins/test_action.py
@@ -34,9 +34,9 @@ class ActionPluginTests(TestCase):
 
     def test_action_name(self):
         """check the name definition possibilities"""
-        self.assertEqual(self.plugin.plugin_name(), '')
-        self.assertEqual(self.action_plugin.plugin_name(), 'abc123')
-        self.assertEqual(self.action_name.plugin_name(), 'Aplugin')
+        self.assertEqual(self.plugin.action_name(), '')
+        self.assertEqual(self.action_plugin.action_name(), 'abc123')
+        self.assertEqual(self.action_name.action_name(), 'Aplugin')
 
     def test_function(self):
         """check functions"""
@@ -51,10 +51,10 @@ class ActionPluginTests(TestCase):
         })
 
         # overriden functions
-        self.assertEqual(self.plugin.perform_action(), self.ACTION_RETURN + 'action')
-        self.assertEqual(self.plugin.get_result(), self.ACTION_RETURN + 'result')
-        self.assertIsNone(self.plugin.get_info(), self.ACTION_RETURN + 'info')
-        self.assertEqual(self.plugin.get_response(), {
+        self.assertEqual(self.action_plugin.perform_action(), self.ACTION_RETURN + 'action')
+        self.assertEqual(self.action_plugin.get_result(), self.ACTION_RETURN + 'result')
+        self.assertIsNone(self.action_plugin.get_info(), self.ACTION_RETURN + 'info')
+        self.assertEqual(self.action_plugin.get_response(), {
             "action": 'abc123',
             "result": self.ACTION_RETURN + 'result',
             "info": self.ACTION_RETURN + 'info',

From 96efba5d85f06f06a9aad9e50ded0d56ce4af683 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 29 Sep 2021 00:08:29 +0200
Subject: [PATCH 107/493] fix names now?

---
 InvenTree/plugins/test_action.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/test_action.py b/InvenTree/plugins/test_action.py
index 7b539cbfcc..11641da406 100644
--- a/InvenTree/plugins/test_action.py
+++ b/InvenTree/plugins/test_action.py
@@ -16,14 +16,14 @@ class ActionPluginTests(TestCase):
             """a action plugin"""
             ACTION_NAME = 'abc123'
 
-            def perform_action():
-                return self.ACTION_RETURN + 'action'
+            def perform_action(self):
+                return ActionPluginTests.ACTION_RETURN + 'action'
 
-            def get_result():
-                return self.ACTION_RETURN + 'result'
+            def get_result(self):
+                return ActionPluginTests.ACTION_RETURN + 'result'
 
-            def get_info():
-                return self.ACTION_RETURN + 'info'
+            def get_info(self):
+                return ActionPluginTests.ACTION_RETURN + 'info'
 
         self.action_plugin = TestActionPlugin('user')
 

From 2b2238049a36ddb540a4b31b4b3c980bc7705e98 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 29 Sep 2021 00:12:22 +0200
Subject: [PATCH 108/493] reading might help...

---
 InvenTree/plugins/test_action.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_action.py b/InvenTree/plugins/test_action.py
index 11641da406..fb0b7b5aa4 100644
--- a/InvenTree/plugins/test_action.py
+++ b/InvenTree/plugins/test_action.py
@@ -53,7 +53,7 @@ class ActionPluginTests(TestCase):
         # overriden functions
         self.assertEqual(self.action_plugin.perform_action(), self.ACTION_RETURN + 'action')
         self.assertEqual(self.action_plugin.get_result(), self.ACTION_RETURN + 'result')
-        self.assertIsNone(self.action_plugin.get_info(), self.ACTION_RETURN + 'info')
+        self.assertEqual(self.action_plugin.get_info(), self.ACTION_RETURN + 'info')
         self.assertEqual(self.action_plugin.get_response(), {
             "action": 'abc123',
             "result": self.ACTION_RETURN + 'result',

From cfef5d63b3481b6abb4b9b02ca38d95524aacf92 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 29 Sep 2021 00:24:09 +0200
Subject: [PATCH 109/493] test sample code

---
 .../plugins/samples/action/test_samples.py    | 38 +++++++++++++++++++
 .../samples/integration/test_samples.py       | 21 ++++++++++
 2 files changed, 59 insertions(+)
 create mode 100644 InvenTree/plugins/samples/action/test_samples.py
 create mode 100644 InvenTree/plugins/samples/integration/test_samples.py

diff --git a/InvenTree/plugins/samples/action/test_samples.py b/InvenTree/plugins/samples/action/test_samples.py
new file mode 100644
index 0000000000..d306828664
--- /dev/null
+++ b/InvenTree/plugins/samples/action/test_samples.py
@@ -0,0 +1,38 @@
+""" Unit tests for action plugins """
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+
+from plugins.samples.action.simpleactionplugin import SimpleActionPlugin
+
+
+class ActionPluginTests(TestCase):
+    """ Tests for SampleIntegrationPlugin """
+
+    def setUp(self):
+        # Create a user for auth
+        user = get_user_model()
+        user.objects.create_user('testuser', 'test@testing.com', 'password')
+
+        self.client.login(username='testuser', password='password')
+
+        self.plugin = SimpleActionPlugin
+
+    def test_name(self):
+        """check plugn names """
+        self.assertEqual(self.plugin.plugin_name(), "SimpleActionPlugin")
+        self.assertEqual(self.plugin.action_name(), "simple")
+
+    def test_function(self):
+        """check if functions work """
+        # test functions
+        respone = self.client.get('/action/sample/')
+        self.assertEqual(respone.status_code, 200)
+        self.assertEqual(respone.content, {
+            "action": 'simple',
+            "result": True,
+            "info": {
+                "user": "testuser",
+                "hello": "world",
+            },
+        })
diff --git a/InvenTree/plugins/samples/integration/test_samples.py b/InvenTree/plugins/samples/integration/test_samples.py
new file mode 100644
index 0000000000..7a82349df6
--- /dev/null
+++ b/InvenTree/plugins/samples/integration/test_samples.py
@@ -0,0 +1,21 @@
+""" Unit tests for action plugins """
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+
+
+class ActionPluginTests(TestCase):
+    """ Tests for SampleIntegrationPlugin """
+
+    def setUp(self):
+        # Create a user for auth
+        user = get_user_model()
+        user.objects.create_user('testuser', 'test@testing.com', 'password')
+
+        self.client.login(username='testuser', password='password')
+
+    def test_view(self):
+        """check the function of the custom  sample plugin """
+        respone = self.client.get('/plugin/SampleIntegrationPlugin/ho/he/')
+        self.assertEqual(respone.status_code, 200)
+        self.assertEqual(respone.content, b'Hi there testuser this work')

From 9a60cc0409d30c085e75c2141410f91208cf7b9d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 29 Sep 2021 00:26:02 +0200
Subject: [PATCH 110/493] cleanup

---
 InvenTree/plugins/test_integration.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 8055252a92..cebdb0dd02 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPlugin, SettingsMixin, UrlsMixin, NavigationMixin  # MixinBase
+from plugins.integration import IntegrationPlugin, SettingsMixin, UrlsMixin, NavigationMixin 
 
 
 class BaseMixinDefinition:
@@ -13,8 +13,6 @@ class BaseMixinDefinition:
         self.assertEqual(self.mixin.registered_mixins[0]['key'], self.MIXIN_NAME)
         # human name
         self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
-        # mixin check
-        #  self.assertEqual(self.mixin.plugin_name(), self.MIXIN_ENABLE_CHECK)
 
 
 class SettingsMixinTest(BaseMixinDefinition, TestCase):

From 3f8ac701624404a3a09f258664abc750438b715c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 29 Sep 2021 00:31:50 +0200
Subject: [PATCH 111/493] PEP fix

---
 InvenTree/plugins/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index cebdb0dd02..ffadbbbbc2 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPlugin, SettingsMixin, UrlsMixin, NavigationMixin 
+from plugins.integration import IntegrationPlugin, SettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:

From 9469c17be16c1c93f98c5bcf4723122c23a92817 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 29 Sep 2021 00:56:01 +0200
Subject: [PATCH 112/493] refactor

---
 .../samples/action/{test_samples.py => test_samples_action.py}  | 2 +-
 .../{test_samples.py => test_samples_integration.py}            | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)
 rename InvenTree/plugins/samples/action/{test_samples.py => test_samples_action.py} (96%)
 rename InvenTree/plugins/samples/integration/{test_samples.py => test_samples_integration.py} (93%)

diff --git a/InvenTree/plugins/samples/action/test_samples.py b/InvenTree/plugins/samples/action/test_samples_action.py
similarity index 96%
rename from InvenTree/plugins/samples/action/test_samples.py
rename to InvenTree/plugins/samples/action/test_samples_action.py
index d306828664..c0b3e8cd4b 100644
--- a/InvenTree/plugins/samples/action/test_samples.py
+++ b/InvenTree/plugins/samples/action/test_samples_action.py
@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
 from plugins.samples.action.simpleactionplugin import SimpleActionPlugin
 
 
-class ActionPluginTests(TestCase):
+class SimpleActionPluginTests(TestCase):
     """ Tests for SampleIntegrationPlugin """
 
     def setUp(self):
diff --git a/InvenTree/plugins/samples/integration/test_samples.py b/InvenTree/plugins/samples/integration/test_samples_integration.py
similarity index 93%
rename from InvenTree/plugins/samples/integration/test_samples.py
rename to InvenTree/plugins/samples/integration/test_samples_integration.py
index 7a82349df6..678d011bf1 100644
--- a/InvenTree/plugins/samples/integration/test_samples.py
+++ b/InvenTree/plugins/samples/integration/test_samples_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.contrib.auth import get_user_model
 
 
-class ActionPluginTests(TestCase):
+class SampleIntegrationPluginTests(TestCase):
     """ Tests for SampleIntegrationPlugin """
 
     def setUp(self):

From eaffd5fd0c8d4b3b6e5309444dea7593b0860a46 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 1 Oct 2021 17:10:36 +0200
Subject: [PATCH 113/493] add method to access plugin setting faster

---
 InvenTree/plugins/integration.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 69ec3d22a3..5e926e7ff1 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -85,6 +85,13 @@ class SettingsMixin:
             return {f'PLUGIN_{self.plugin_name().upper()}_{key}': value for key, value in self.settings.items()}
         return None
 
+    def get_setting(self, key):
+        """
+        get plugin setting by key
+        """
+        from common.models import InvenTreeSetting
+        return InvenTreeSetting.get_setting(f'PLUGIN_{self.PLUGIN_NAME.upper()}_{key}')
+
 
 class UrlsMixin:
     """Mixin that enables urls for the plugin"""

From 04eee506533eaac66b5a1d727661cefd629ae546 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 13:36:42 +0200
Subject: [PATCH 114/493] initiate plugins on startup

---
 InvenTree/InvenTree/settings.py | 5 +++--
 InvenTree/InvenTree/urls.py     | 4 +---
 2 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 3388d7087e..dcbb4be052 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -652,14 +652,15 @@ MESSAGE_TAGS = {
 # Plugins
 PLUGIN_URL = 'plugin'
 
-INTEGRATION_PLUGINS = inventree_plugins.load_integration_plugins()
+INTEGRATION_PLUGINS = []
+for plugin in inventree_plugins.load_integration_plugins():
+    INTEGRATION_PLUGINS.append(plugin())
 
 INTEGRATION_PLUGIN_SETTINGS = {}
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_PLUGIN_LIST = {}
 
 for plugin in INTEGRATION_PLUGINS:
-    plugin = plugin()
     INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
     if plugin.mixin_enabled('settings'):
         INTEGRATION_PLUGIN_SETTING[plugin.plugin_name()] = plugin.settingspatterns
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 0d75fb2938..34980b46d0 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -128,11 +128,9 @@ translated_javascript_urls = [
 ]
 
 # Integration plugin urls
-integration_plugins = inventree_plugins.load_integration_plugins()
+integration_plugins = settings.INTEGRATION_PLUGINS
 interation_urls = []
 for plugin in integration_plugins:
-    # initialize
-    plugin = plugin()
     if plugin.mixin_enabled('urls'):
         interation_urls.append(plugin.urlpatterns)
 

From 9695d50bf432e71e1da8c17338473125757e6e0c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 13:39:11 +0200
Subject: [PATCH 115/493] enable templates for plugins

---
 InvenTree/InvenTree/settings.py  |  8 +++++++-
 InvenTree/plugins/integration.py |  5 +++--
 InvenTree/plugins/loader.py      | 19 +++++++++++++++++++
 3 files changed, 29 insertions(+), 3 deletions(-)
 create mode 100644 InvenTree/plugins/loader.py

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index dcbb4be052..49e2a38d1e 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -320,7 +320,6 @@ TEMPLATES = [
             os.path.join(MEDIA_ROOT, 'report'),
             os.path.join(MEDIA_ROOT, 'label'),
         ],
-        'APP_DIRS': True,
         'OPTIONS': {
             'context_processors': [
                 'django.template.context_processors.debug',
@@ -333,6 +332,13 @@ TEMPLATES = [
                 'InvenTree.context.status_codes',
                 'InvenTree.context.user_roles',
             ],
+            'loaders': [(
+                'django.template.loaders.cached.Loader', [
+                    'django.template.loaders.app_directories.Loader',
+                    'django.template.loaders.filesystem.Loader',
+                    'plugins.loader.PluginTemplateLoader',
+                ])
+            ],
         },
     },
 ]
diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 5e926e7ff1..194c974939 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -214,6 +214,8 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
     def __init__(self):
         super().__init__()
         self.add_mixin('base')
+        self.def_path = inspect.getfile(self.__class__)
+        self.path = os.path.dirname(self.def_path)
 
         self.set_sign_values()
 
@@ -230,8 +232,7 @@ class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
 
     def get_plugin_commit(self):
         """get last git commit for plugin"""
-        path = inspect.getfile(self.__class__)
-        return get_git_log(path)
+        return get_git_log(self.def_path)
 
     def set_sign_values(self):
         """add the last commit of the plugins class file into plugins context"""
diff --git a/InvenTree/plugins/loader.py b/InvenTree/plugins/loader.py
new file mode 100644
index 0000000000..82680d534d
--- /dev/null
+++ b/InvenTree/plugins/loader.py
@@ -0,0 +1,19 @@
+"""
+load templates for loaded plugins
+"""
+from django.conf import settings
+
+from django.template.loaders.filesystem import Loader as FilesystemLoader
+from pathlib import Path
+
+
+class PluginTemplateLoader(FilesystemLoader):
+
+    def get_dirs(self):
+        dirname = 'templates'
+        template_dirs = []
+        for plugin in settings.INTEGRATION_PLUGINS:
+            new_path = Path(plugin.path) / dirname
+            if Path(new_path).is_dir():
+                template_dirs.append(new_path)
+        return tuple(template_dirs)

From cd343d224b4d534576750c14d4636b8f9e275899 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 13:44:30 +0200
Subject: [PATCH 116/493] PEP fix

---
 InvenTree/InvenTree/urls.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 34980b46d0..83e417ab64 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -48,8 +48,6 @@ from common.views import SettingEdit, UserSettingEdit
 from .api import InfoView, NotFoundView
 from .api import ActionPluginView
 
-from plugins import plugins as inventree_plugins
-
 from users.api import user_urls
 
 admin.site.site_header = "InvenTree Admin"

From 8fc2610a9d0b64031bf9f34f9ba2f57e9bc7b40e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 14:16:21 +0200
Subject: [PATCH 117/493] simplify loading

---
 InvenTree/InvenTree/settings.py | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 49e2a38d1e..3ba94315d7 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -659,15 +659,19 @@ MESSAGE_TAGS = {
 PLUGIN_URL = 'plugin'
 
 INTEGRATION_PLUGINS = []
-for plugin in inventree_plugins.load_integration_plugins():
-    INTEGRATION_PLUGINS.append(plugin())
 
 INTEGRATION_PLUGIN_SETTINGS = {}
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_PLUGIN_LIST = {}
 
-for plugin in INTEGRATION_PLUGINS:
-    INTEGRATION_PLUGIN_LIST[plugin.plugin_name()] = plugin
+for plugin in inventree_plugins.load_integration_plugins():
+    plugin = plugin()
+    plugin_name = plugin.plugin_name()
+
+    INTEGRATION_PLUGINS.append(plugin)
+    INTEGRATION_PLUGIN_LIST[plugin_name] = plugin
     if plugin.mixin_enabled('settings'):
-        INTEGRATION_PLUGIN_SETTING[plugin.plugin_name()] = plugin.settingspatterns
-        INTEGRATION_PLUGIN_SETTINGS.update(plugin.settingspatterns)
+        plugin_setting = plugin.settingspatterns
+
+        INTEGRATION_PLUGIN_SETTING[plugin_name] = plugin_setting
+        INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)

From 094feec495d35218ffd5fdc8ab578597ae8e563f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 14:23:13 +0200
Subject: [PATCH 118/493] also run tests in samples

---
 InvenTree/plugins/samples/__init__.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 InvenTree/plugins/samples/__init__.py

diff --git a/InvenTree/plugins/samples/__init__.py b/InvenTree/plugins/samples/__init__.py
new file mode 100644
index 0000000000..e69de29bb2

From fadf4d5ca82416abc54edcd0976e4cbd28ebddfe Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 14:25:40 +0200
Subject: [PATCH 119/493] fix tests

---
 InvenTree/plugins/samples/action/test_samples_action.py         | 2 +-
 .../plugins/samples/integration/test_samples_integration.py     | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/samples/action/test_samples_action.py b/InvenTree/plugins/samples/action/test_samples_action.py
index c0b3e8cd4b..865a7c10e9 100644
--- a/InvenTree/plugins/samples/action/test_samples_action.py
+++ b/InvenTree/plugins/samples/action/test_samples_action.py
@@ -16,7 +16,7 @@ class SimpleActionPluginTests(TestCase):
 
         self.client.login(username='testuser', password='password')
 
-        self.plugin = SimpleActionPlugin
+        self.plugin = SimpleActionPlugin()
 
     def test_name(self):
         """check plugn names """
diff --git a/InvenTree/plugins/samples/integration/test_samples_integration.py b/InvenTree/plugins/samples/integration/test_samples_integration.py
index 678d011bf1..014ed9f543 100644
--- a/InvenTree/plugins/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugins/samples/integration/test_samples_integration.py
@@ -18,4 +18,4 @@ class SampleIntegrationPluginTests(TestCase):
         """check the function of the custom  sample plugin """
         respone = self.client.get('/plugin/SampleIntegrationPlugin/ho/he/')
         self.assertEqual(respone.status_code, 200)
-        self.assertEqual(respone.content, b'Hi there testuser this work')
+        self.assertEqual(respone.content, b'Hi there testuser this works')

From d977aac6a08244d2d298d66da9f2fdfed4d2f3d0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 14:51:37 +0200
Subject: [PATCH 120/493] fix test for actions

---
 .../samples/action/test_samples_action.py     | 26 ++++++++++---------
 1 file changed, 14 insertions(+), 12 deletions(-)

diff --git a/InvenTree/plugins/samples/action/test_samples_action.py b/InvenTree/plugins/samples/action/test_samples_action.py
index 865a7c10e9..76fd5f4c2e 100644
--- a/InvenTree/plugins/samples/action/test_samples_action.py
+++ b/InvenTree/plugins/samples/action/test_samples_action.py
@@ -12,11 +12,10 @@ class SimpleActionPluginTests(TestCase):
     def setUp(self):
         # Create a user for auth
         user = get_user_model()
-        user.objects.create_user('testuser', 'test@testing.com', 'password')
+        self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
 
         self.client.login(username='testuser', password='password')
-
-        self.plugin = SimpleActionPlugin()
+        self.plugin = SimpleActionPlugin(user=self.test_user)
 
     def test_name(self):
         """check plugn names """
@@ -26,13 +25,16 @@ class SimpleActionPluginTests(TestCase):
     def test_function(self):
         """check if functions work """
         # test functions
-        respone = self.client.get('/action/sample/')
+        respone = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar",}})
         self.assertEqual(respone.status_code, 200)
-        self.assertEqual(respone.content, {
-            "action": 'simple',
-            "result": True,
-            "info": {
-                "user": "testuser",
-                "hello": "world",
-            },
-        })
+        self.assertJSONEqual(
+            str(respone.content, encoding='utf8'),
+            {
+                "action": 'simple',
+                "result": True,
+                "info": {
+                    "user": "testuser",
+                    "hello": "world",
+                },
+            }
+        )

From c5fc8ba6ab0c9df6c9aa43640c62ee9f8c8619c9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 14:52:36 +0200
Subject: [PATCH 121/493] typo fix

---
 InvenTree/plugins/samples/action/test_samples_action.py     | 6 +++---
 .../plugins/samples/integration/test_samples_integration.py | 6 +++---
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugins/samples/action/test_samples_action.py b/InvenTree/plugins/samples/action/test_samples_action.py
index 76fd5f4c2e..27b4a16150 100644
--- a/InvenTree/plugins/samples/action/test_samples_action.py
+++ b/InvenTree/plugins/samples/action/test_samples_action.py
@@ -25,10 +25,10 @@ class SimpleActionPluginTests(TestCase):
     def test_function(self):
         """check if functions work """
         # test functions
-        respone = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar",}})
-        self.assertEqual(respone.status_code, 200)
+        response = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar",}})
+        self.assertEqual(response.status_code, 200)
         self.assertJSONEqual(
-            str(respone.content, encoding='utf8'),
+            str(response.content, encoding='utf8'),
             {
                 "action": 'simple',
                 "result": True,
diff --git a/InvenTree/plugins/samples/integration/test_samples_integration.py b/InvenTree/plugins/samples/integration/test_samples_integration.py
index 014ed9f543..cc39b730df 100644
--- a/InvenTree/plugins/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugins/samples/integration/test_samples_integration.py
@@ -16,6 +16,6 @@ class SampleIntegrationPluginTests(TestCase):
 
     def test_view(self):
         """check the function of the custom  sample plugin """
-        respone = self.client.get('/plugin/SampleIntegrationPlugin/ho/he/')
-        self.assertEqual(respone.status_code, 200)
-        self.assertEqual(respone.content, b'Hi there testuser this works')
+        response = self.client.get('/plugin/SampleIntegrationPlugin/ho/he/')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.content, b'Hi there testuser this works')

From b31c7ccd2417f43a531f5b2e7adfa72e575e2ea2 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 3 Oct 2021 15:02:47 +0200
Subject: [PATCH 122/493] PEP fix

---
 InvenTree/plugins/samples/action/test_samples_action.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/samples/action/test_samples_action.py b/InvenTree/plugins/samples/action/test_samples_action.py
index 27b4a16150..595c3fa948 100644
--- a/InvenTree/plugins/samples/action/test_samples_action.py
+++ b/InvenTree/plugins/samples/action/test_samples_action.py
@@ -25,7 +25,7 @@ class SimpleActionPluginTests(TestCase):
     def test_function(self):
         """check if functions work """
         # test functions
-        response = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar",}})
+        response = self.client.post('/api/action/', data={'action': "simple", 'data': {'foo': "bar", }})
         self.assertEqual(response.status_code, 200)
         self.assertJSONEqual(
             str(response.content, encoding='utf8'),

From 0333b3fc728c5cc7883092137171021acec16e1a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 00:48:21 +0200
Subject: [PATCH 123/493] mixin for full app functions mainly migrations right
 now

---
 InvenTree/InvenTree/settings.py  |  5 +++++
 InvenTree/plugins/integration.py | 19 +++++++++++++++++++
 2 files changed, 24 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 3ba94315d7..7b511e0f9b 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -18,6 +18,7 @@ import random
 import string
 import shutil
 import sys
+import pathlib
 from datetime import datetime
 
 import moneyed
@@ -675,3 +676,7 @@ for plugin in inventree_plugins.load_integration_plugins():
 
         INTEGRATION_PLUGIN_SETTING[plugin_name] = plugin_setting
         INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
+
+    if plugin.mixin_enabled('app'):
+        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(BASE_DIR).parts)
+        INSTALLED_APPS += [plugin_path]
diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 194c974939..a8e8a6d352 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -163,6 +163,25 @@ class NavigationMixin:
         does this plugin define navigation elements
         """
         return bool(self.navigation)
+
+
+class AppMixin:
+    """Mixin that enables full django app functions for a plugin"""
+    class Meta:
+        """meta options for this mixin"""
+        MIXIN_NAME = 'App registration'
+
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('app', 'has_app', __class__)
+
+    @property
+    def has_app(self):
+        """
+        this plugin is always an app with this plugin
+        """
+        return True
+
 # endregion
 
 

From b416a13cc812603bf446c2ebb9dfd4ea4e3beee3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 00:53:01 +0200
Subject: [PATCH 124/493] rename to make function of base class clearer

---
 InvenTree/plugins/integration.py                   |  6 +++---
 InvenTree/plugins/plugins.py                       |  4 ++--
 .../plugins/samples/integration/another_sample.py  |  6 +++---
 InvenTree/plugins/samples/integration/sample.py    |  4 ++--
 InvenTree/plugins/test_integration.py              | 14 +++++++-------
 5 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index a8e8a6d352..d376f7c651 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-"""class for IntegrationPlugin and Mixins for it"""
+"""class for IntegrationPluginBase and Mixins for it"""
 
 import logging
 import os
@@ -225,9 +225,9 @@ class GitStatus:
 # endregion
 
 
-class IntegrationPlugin(MixinBase, plugin.InvenTreePlugin):
+class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     """
-    The IntegrationPlugin class is used to integrate with 3rd party software
+    The IntegrationPluginBase class is used to integrate with 3rd party software
     """
 
     def __init__(self):
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugins/plugins.py
index 0435a023f4..142e22e6a0 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugins/plugins.py
@@ -11,7 +11,7 @@ import plugins.samples.action as action
 from plugins.action import ActionPlugin
 
 import plugins.samples.integration as integration
-from plugins.integration import IntegrationPlugin
+from plugins.integration import IntegrationPluginBase
 
 
 logger = logging.getLogger("inventree")
@@ -88,7 +88,7 @@ def load_integration_plugins():
     """
     Return a list of all registered integration plugins
     """
-    return load_plugins('integration', integration, IntegrationPlugin)
+    return load_plugins('integration', integration, IntegrationPluginBase)
 
 
 def load_barcode_plugins():
diff --git a/InvenTree/plugins/samples/integration/another_sample.py b/InvenTree/plugins/samples/integration/another_sample.py
index fc3f8d1b23..800481dee4 100644
--- a/InvenTree/plugins/samples/integration/another_sample.py
+++ b/InvenTree/plugins/samples/integration/another_sample.py
@@ -1,8 +1,8 @@
 """sample implementation for IntegrationPlugin"""
-from plugins.integration import IntegrationPlugin, UrlsMixin
+from plugins.integration import IntegrationPluginBase, UrlsMixin
 
 
-class NoIntegrationPlugin(IntegrationPlugin):
+class NoIntegrationPlugin(IntegrationPluginBase):
     """
     An basic integration plugin
     """
@@ -10,7 +10,7 @@ class NoIntegrationPlugin(IntegrationPlugin):
     PLUGIN_NAME = "NoIntegrationPlugin"
 
 
-class WrongIntegrationPlugin(UrlsMixin, IntegrationPlugin):
+class WrongIntegrationPlugin(UrlsMixin, IntegrationPluginBase):
     """
     An basic integration plugin
     """
diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index 8e1c7c6894..447c39057a 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -1,12 +1,12 @@
 """sample implementations for IntegrationPlugin"""
-from plugins.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin
+from plugins.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
 from django.conf.urls import url, include
 
 
-class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPlugin):
+class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
     """
     An full integration plugin
     """
diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index ffadbbbbc2..0f28674021 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPlugin, SettingsMixin, UrlsMixin, NavigationMixin
+from plugins.integration import IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:
@@ -23,11 +23,11 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
     TEST_SETTINGS = {'setting1': [1, 2, 3]}
 
     def setUp(self):
-        class SettingsCls(SettingsMixin, IntegrationPlugin):
+        class SettingsCls(SettingsMixin, IntegrationPluginBase):
             SETTINGS = self.TEST_SETTINGS
         self.mixin = SettingsCls()
 
-        class NoSettingsCls(SettingsMixin, IntegrationPlugin):
+        class NoSettingsCls(SettingsMixin, IntegrationPluginBase):
             pass
         self.mixin_nothing = NoSettingsCls()
 
@@ -50,13 +50,13 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_ENABLE_CHECK = 'has_urls'
 
     def setUp(self):
-        class UrlsCls(UrlsMixin, IntegrationPlugin):
+        class UrlsCls(UrlsMixin, IntegrationPluginBase):
             def test():
                 return 'ccc'
             URLS = [url('testpath', test, name='test'), ]
         self.mixin = UrlsCls()
 
-        class NoUrlsCls(UrlsMixin, IntegrationPlugin):
+        class NoUrlsCls(UrlsMixin, IntegrationPluginBase):
             pass
         self.mixin_nothing = NoUrlsCls()
 
@@ -86,7 +86,7 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_ENABLE_CHECK = 'has_naviation'
 
     def setUp(self):
-        class NavigationCls(NavigationMixin, IntegrationPlugin):
+        class NavigationCls(NavigationMixin, IntegrationPluginBase):
             NAVIGATION = [
                 {'name': 'aa', 'link': 'plugin:test:test_view'},
             ]
@@ -97,6 +97,6 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
         self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
         # check wrong links fails
         with self.assertRaises(NotImplementedError):
-            class NavigationCls(NavigationMixin, IntegrationPlugin):
+            class NavigationCls(NavigationMixin, IntegrationPluginBase):
                 NAVIGATION = ['aa', 'aa']
             NavigationCls()

From 7654d176cbe2cc8a875228ac65de0a8d8d6d39e8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 07:59:07 +0200
Subject: [PATCH 125/493] slugs for plugins

---
 InvenTree/InvenTree/settings.py               |  5 ++---
 InvenTree/plugins/integration.py              | 22 +++++++++++++++----
 .../plugins/samples/integration/sample.py     |  3 ++-
 InvenTree/plugins/test_integration.py         |  2 +-
 4 files changed, 23 insertions(+), 9 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 7b511e0f9b..bd69fafc93 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -667,14 +667,13 @@ INTEGRATION_PLUGIN_LIST = {}
 
 for plugin in inventree_plugins.load_integration_plugins():
     plugin = plugin()
-    plugin_name = plugin.plugin_name()
 
     INTEGRATION_PLUGINS.append(plugin)
-    INTEGRATION_PLUGIN_LIST[plugin_name] = plugin
+    INTEGRATION_PLUGIN_LIST[plugin.slug] = plugin
     if plugin.mixin_enabled('settings'):
         plugin_setting = plugin.settingspatterns
 
-        INTEGRATION_PLUGIN_SETTING[plugin_name] = plugin_setting
+        INTEGRATION_PLUGIN_SETTING[plugin.slug] = plugin_setting
         INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
 
     if plugin.mixin_enabled('app'):
diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index d376f7c651..f8641af6cd 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -8,6 +8,7 @@ import inspect
 
 from django.conf.urls import url, include
 from django.conf import settings
+from django.utils.text import slugify
 
 import plugins.plugin as plugin
 
@@ -82,7 +83,7 @@ class SettingsMixin:
         get patterns for InvenTreeSetting defintion
         """
         if self.has_settings:
-            return {f'PLUGIN_{self.plugin_name().upper()}_{key}': value for key, value in self.settings.items()}
+            return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.settings.items()}
         return None
 
     def get_setting(self, key):
@@ -90,7 +91,7 @@ class SettingsMixin:
         get plugin setting by key
         """
         from common.models import InvenTreeSetting
-        return InvenTreeSetting.get_setting(f'PLUGIN_{self.PLUGIN_NAME.upper()}_{key}')
+        return InvenTreeSetting.get_setting(f'PLUGIN_{self.slug.upper()}_{key}')
 
 
 class UrlsMixin:
@@ -115,7 +116,14 @@ class UrlsMixin:
         """
         returns base url for this plugin
         """
-        return f'{settings.PLUGIN_URL}/{self.plugin_name()}/'
+        return f'{settings.PLUGIN_URL}/{self.slug}/'
+
+    @property
+    def internal_name(self):
+        """
+        returns the internal url pattern name
+        """
+        return f'plugin:{self.slug}:'
 
     @property
     def urlpatterns(self):
@@ -123,7 +131,7 @@ class UrlsMixin:
         returns the urlpatterns for this plugin
         """
         if self.has_urls:
-            return url(f'^{self.plugin_name()}/', include((self.urls, self.plugin_name())), name=self.plugin_name())
+            return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug)
         return None
 
     @property
@@ -238,6 +246,12 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
         self.set_sign_values()
 
+    @property
+    def slug(self):
+        """slug for the plugin"""
+        name = getattr(self, 'PLUGIN_SLUG', self.plugin_name())
+        return slugify(name)
+
     def mixin(self, key):
         """check if mixin is registered"""
         return key in self._mixins
diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index 447c39057a..34d5b78786 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -12,6 +12,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     """
 
     PLUGIN_NAME = "SampleIntegrationPlugin"
+    PLUGIN_SLUG= "sample"
 
     def view_test(self, request):
         """very basic view"""
@@ -38,5 +39,5 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     }
 
     NAVIGATION = [
-        {'name': 'SampleIntegration', 'link': 'plugin:SampleIntegrationPlugin:hi'},
+        {'name': 'SampleIntegration', 'link': 'plugin:sample:hi'},
     ]
diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 0f28674021..0bfbbeb530 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -36,7 +36,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
 
         # settings pattern
-        target_pattern = {f'PLUGIN_{self.mixin.plugin_name().upper()}_{key}': value for key, value in self.mixin.settings.items()}
+        target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.settings.items()}
         self.assertEqual(self.mixin.settingspatterns, target_pattern)
 
         # no settings

From 63e7a9caee61e481f445d5810fb5978db8c9972c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 12:06:27 +0200
Subject: [PATCH 126/493] PEP fix

---
 InvenTree/plugins/samples/integration/sample.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index 34d5b78786..59b3038382 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -12,7 +12,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     """
 
     PLUGIN_NAME = "SampleIntegrationPlugin"
-    PLUGIN_SLUG= "sample"
+    PLUGIN_SLUG = "sample"
 
     def view_test(self, request):
         """very basic view"""

From d6145fc803778f1eddb08c6396bff13a7d88eecc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 12:07:35 +0200
Subject: [PATCH 127/493] fix test

---
 .../plugins/samples/integration/test_samples_integration.py     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/samples/integration/test_samples_integration.py b/InvenTree/plugins/samples/integration/test_samples_integration.py
index cc39b730df..733e443638 100644
--- a/InvenTree/plugins/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugins/samples/integration/test_samples_integration.py
@@ -16,6 +16,6 @@ class SampleIntegrationPluginTests(TestCase):
 
     def test_view(self):
         """check the function of the custom  sample plugin """
-        response = self.client.get('/plugin/SampleIntegrationPlugin/ho/he/')
+        response = self.client.get('/plugin/sample/ho/he/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b'Hi there testuser this works')

From d1c2a399ebb5f1a42d20f620d9e4a62c51d1365b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 12:19:43 +0200
Subject: [PATCH 128/493] Default value for SLug is None

---
 InvenTree/plugins/integration.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index f8641af6cd..60716773e5 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -237,6 +237,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     """
     The IntegrationPluginBase class is used to integrate with 3rd party software
     """
+    PLUGIN_SLUG = None
 
     def __init__(self):
         super().__init__()
@@ -249,7 +250,9 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     @property
     def slug(self):
         """slug for the plugin"""
-        name = getattr(self, 'PLUGIN_SLUG', self.plugin_name())
+        name = getattr(self, 'PLUGIN_SLUG', None)
+        if not name:
+            name = self.plugin_name()
         return slugify(name)
 
     def mixin(self, key):

From 258d159093b0677d94e69209e3317550687156b2 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 12:31:12 +0200
Subject: [PATCH 129/493] human name for plugins

---
 InvenTree/plugins/integration.py                          | 8 ++++++++
 InvenTree/templates/InvenTree/settings/plugin.html        | 4 ++--
 .../templates/InvenTree/settings/plugin_settings.html     | 2 +-
 InvenTree/templates/navbar.html                           | 2 +-
 4 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 60716773e5..10d8b58d66 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -255,6 +255,14 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             name = self.plugin_name()
         return slugify(name)
 
+    @property
+    def human_name(self):
+        """human readable name for labels etc."""
+        name = getattr(self, 'PLUGIN_TITLE', None)
+        if not name:
+            name = self.plugin_name()
+        return name
+
     def mixin(self, key):
         """check if mixin is registered"""
         return key in self._mixins
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 30db6966c8..6cc340f164 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -30,7 +30,7 @@
         {% mixin_enabled plugin 'settings' as settings %}
 
         <tr>
-            <td>{{plugin_key}} - {{ plugin.plugin_name}}
+            <td>{{plugin_key}} - {{ plugin.human_name }}
                 {% if urls %}
                     <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has urls' %}</a></span>
                 {% endif %}
@@ -42,7 +42,7 @@
                 {% if mixin_list %}
                 {% for mixin in mixin_list %}
                     <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>
-                        {% blocktrans with name=mixin.human_name%}has {{name}}{% endblocktrans %}</a></span>
+                        {% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</a></span>
                 {% endfor %}
                 {% endif %}
             </td>
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 2769093365..26c354be62 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -7,7 +7,7 @@
 
 
 {% block heading %}
-{% blocktrans with name=plugin.plugin_name %}Plugin details for {{name}}{% endblocktrans %}
+{% blocktrans with name=plugin.human_name %}Plugin details for {{name}}{% endblocktrans %}
 {% endblock %}
 
 {% block content %}
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index c6ea7e4761..ef3d4066a8 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -65,7 +65,7 @@
           {% if navigation %}
 
           <li class='nav navbar-nav'>
-            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.plugin_name}}</a>
+            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.human_name}}</a>
             <ul class='dropdown-menu'>
              {% for nav_item in plugin.navigation %}
                 <li><a href="{% url nav_item.link %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>

From 9ed502f1ae664716eae61208bd23f2eaf5a871a8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 12:31:31 +0200
Subject: [PATCH 130/493] plugin name nicer

---
 InvenTree/plugins/samples/integration/sample.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index 59b3038382..d947874e0b 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -13,6 +13,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
 
     PLUGIN_NAME = "SampleIntegrationPlugin"
     PLUGIN_SLUG = "sample"
+    PLUGIN_TITLE = "Sample Plugin"
 
     def view_test(self, request):
         """very basic view"""

From 117baa72ebdddbf9268b299ce4f46ac7b52c0345 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 19:24:41 +0200
Subject: [PATCH 131/493] nav mixin: set nav-tab name

---
 InvenTree/plugins/integration.py | 10 ++++++++++
 InvenTree/templates/navbar.html  |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 10d8b58d66..fdc2ed2105 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -144,6 +144,8 @@ class UrlsMixin:
 
 class NavigationMixin:
     """Mixin that enables adding navigation links with the plugin"""
+    NAVIGATION_TAB_NAME = None
+
     class Meta:
         """meta options for this mixin"""
         MIXIN_NAME = 'Navigation Links'
@@ -172,6 +174,14 @@ class NavigationMixin:
         """
         return bool(self.navigation)
 
+    @property
+    def navigation_name(self):
+        """name for navigation tab"""
+        name = getattr(self, 'NAVIGATION_TAB_NAME', None)
+        if not name:
+            name = self.human_name
+        return name
+
 
 class AppMixin:
     """Mixin that enables full django app functions for a plugin"""
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index ef3d4066a8..754f1739ae 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -65,7 +65,7 @@
           {% if navigation %}
 
           <li class='nav navbar-nav'>
-            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.human_name}}</a>
+            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.navigation_name}}</a>
             <ul class='dropdown-menu'>
              {% for nav_item in plugin.navigation %}
                 <li><a href="{% url nav_item.link %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>

From a5c7676be67d3abeeef71dc2ac7937d2734e4723 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 19:25:42 +0200
Subject: [PATCH 132/493] set nav-tab name

---
 InvenTree/plugins/samples/integration/sample.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index d947874e0b..119bd19d3a 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -15,6 +15,8 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     PLUGIN_SLUG = "sample"
     PLUGIN_TITLE = "Sample Plugin"
 
+    NAVIGATION_TAB_NAME = "Sample Nav"
+
     def view_test(self, request):
         """very basic view"""
         return HttpResponse(f'Hi there {request.user.username} this works')

From 87fff789441bd2e06f896e2213f3e9b9ad1f84ac Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 19:30:54 +0200
Subject: [PATCH 133/493] navigtion-mixin icon setting

---
 InvenTree/plugins/integration.py | 6 ++++++
 InvenTree/templates/navbar.html  | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index fdc2ed2105..628293dfbb 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -145,6 +145,7 @@ class UrlsMixin:
 class NavigationMixin:
     """Mixin that enables adding navigation links with the plugin"""
     NAVIGATION_TAB_NAME = None
+    NAVIGATION_TAB_ICON = "fas fa-question"
 
     class Meta:
         """meta options for this mixin"""
@@ -182,6 +183,11 @@ class NavigationMixin:
             name = self.human_name
         return name
 
+    @property
+    def navigation_icon(self):
+        """icon for navigation tab"""
+        return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
+
 
 class AppMixin:
     """Mixin that enables full django app functions for a plugin"""
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index 754f1739ae..4f0e131541 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -65,7 +65,7 @@
           {% if navigation %}
 
           <li class='nav navbar-nav'>
-            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.icon}} icon-header'></span>{{plugin.navigation_name}}</a>
+            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.navigation_icon}} icon-header'></span>{{plugin.navigation_name}}</a>
             <ul class='dropdown-menu'>
              {% for nav_item in plugin.navigation %}
                 <li><a href="{% url nav_item.link %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>

From 90d36069c84e597de5abbe79ccc05b5c86a823c8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 19:40:55 +0200
Subject: [PATCH 134/493] add navigation icon

---
 InvenTree/plugins/samples/integration/sample.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index 119bd19d3a..adfe74d7f6 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -16,6 +16,7 @@ class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, Integra
     PLUGIN_TITLE = "Sample Plugin"
 
     NAVIGATION_TAB_NAME = "Sample Nav"
+    NAVIGATION_TAB_ICON = 'fas fa-plus'
 
     def view_test(self, request):
         """very basic view"""

From cec29310dc8334c86dd10919b57ea17d759ef1ba Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 19:50:56 +0200
Subject: [PATCH 135/493] struc

---
 InvenTree/plugins/integration.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 628293dfbb..246aa34fd4 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -263,6 +263,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
         self.set_sign_values()
 
+    # properties
     @property
     def slug(self):
         """slug for the plugin"""
@@ -279,6 +280,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             name = self.plugin_name()
         return name
 
+    # mixins
     def mixin(self, key):
         """check if mixin is registered"""
         return key in self._mixins
@@ -290,6 +292,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             return getattr(self, fnc_name, True)
         return False
 
+    # git
     def get_plugin_commit(self):
         """get last git commit for plugin"""
         return get_git_log(self.def_path)

From 575be5b36ade962de2360f6a04a53d5357cf6202 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 19:54:20 +0200
Subject: [PATCH 136/493] author information for integration plugin

---
 InvenTree/plugins/integration.py                   | 13 +++++++++++++
 InvenTree/templates/InvenTree/settings/plugin.html |  2 +-
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 246aa34fd4..76a5add70c 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -9,6 +9,7 @@ import inspect
 from django.conf.urls import url, include
 from django.conf import settings
 from django.utils.text import slugify
+from django.utils.translation import ugettext_lazy as _
 
 import plugins.plugin as plugin
 
@@ -255,6 +256,8 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     """
     PLUGIN_SLUG = None
 
+    AUTHOR = None
+
     def __init__(self):
         super().__init__()
         self.add_mixin('base')
@@ -280,6 +283,16 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             name = self.plugin_name()
         return name
 
+    @property
+    def author(self):
+        """returns author of plugin - either from plugin settings or git"""
+        name = getattr(self, 'AUTHOR', None)
+        if not name:
+            name = self.commit.get('author')
+        if not name:
+            name = _('No author found')
+        return name
+
     # mixins
     def mixin(self, key):
         """check if mixin is registered"""
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 6cc340f164..2a14cb7fa0 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -46,7 +46,7 @@
                 {% endfor %}
                 {% endif %}
             </td>
-            <td># TODO</td>
+            <td>{{ plugin.author }}</td>
             <td>{{plugin.commit.date}}</td>
         </tr>
         {% endfor %}

From ef858f7701fd08042f415348f5d893903a9dab2b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 20:00:27 +0200
Subject: [PATCH 137/493] publishing date for integration plugins

---
 InvenTree/plugins/integration.py                   | 11 +++++++++++
 InvenTree/templates/InvenTree/settings/plugin.html |  2 +-
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 76a5add70c..2177a4f610 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -257,6 +257,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     PLUGIN_SLUG = None
 
     AUTHOR = None
+    PUBLISH_DATE = None
 
     def __init__(self):
         super().__init__()
@@ -293,6 +294,16 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             name = _('No author found')
         return name
 
+    @property
+    def pub_date(self):
+        """returns publishing date of plugin - either from plugin settings or git"""
+        name = getattr(self, 'PUBLISH_DATE', None)
+        if not name:
+            name = self.commit.get('date')
+        if not name:
+            name = _('No date found')
+        return name
+
     # mixins
     def mixin(self, key):
         """check if mixin is registered"""
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 2a14cb7fa0..c3109dda3c 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -47,7 +47,7 @@
                 {% endif %}
             </td>
             <td>{{ plugin.author }}</td>
-            <td>{{plugin.commit.date}}</td>
+            <td>{{ plugin.pub_date }}</td>
         </tr>
         {% endfor %}
     </tbody>

From e7237d13ae2f6743b9870cfec6ba568fc4dc7960 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:10:31 +0200
Subject: [PATCH 138/493] added version identifier

---
 InvenTree/plugins/integration.py                   | 7 +++++++
 InvenTree/templates/InvenTree/settings/plugin.html | 2 ++
 2 files changed, 9 insertions(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 2177a4f610..bf604ea391 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -258,6 +258,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
     AUTHOR = None
     PUBLISH_DATE = None
+    VERSION = None
 
     def __init__(self):
         super().__init__()
@@ -304,6 +305,12 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             name = _('No date found')
         return name
 
+    @property
+    def version(self):
+        """returns version of plugin"""
+        name = getattr(self, 'VERSION', None)
+        return name
+
     # mixins
     def mixin(self, key):
         """check if mixin is registered"""
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index c3109dda3c..92ae800dac 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -20,6 +20,7 @@
             <th>{% trans "Name" %}</th>
             <th>{% trans "Author" %}</th>
             <th>{% trans "Date" %}</th>
+            <th>{% trans "Version" %}</th>
         </tr>
     </thead>
     
@@ -48,6 +49,7 @@
             </td>
             <td>{{ plugin.author }}</td>
             <td>{{ plugin.pub_date }}</td>
+            <td>{{ plugin.version }}</td>
         </tr>
         {% endfor %}
     </tbody>

From 40789db66c475156a3e8b0c759f92c7306b198f9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:20:50 +0200
Subject: [PATCH 139/493] better readable badges

---
 InvenTree/templates/InvenTree/settings/plugin.html | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 92ae800dac..5028760d0b 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -31,19 +31,13 @@
         {% mixin_enabled plugin 'settings' as settings %}
 
         <tr>
-            <td>{{plugin_key}} - {{ plugin.human_name }}
-                {% if urls %}
-                    <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has urls' %}</a></span>
-                {% endif %}
-                {% if settings %}
-                    <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>{% trans 'Has settings' %}</a></span>
-                {% endif %}
-
+            <td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
                 {% define plugin.registered_mixins as mixin_list %}
                 {% if mixin_list %}
                 {% for mixin in mixin_list %}
-                    <span class='badge'><a class='nav-toggle text-success' id='select-plugin-{{plugin_key}}'>
-                        {% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</a></span>
+                <a class='nav-toggle' id='select-plugin-{{plugin_key}}'>
+                    <span class='badge badge-badge-secondary'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span>
+                </a>
                 {% endfor %}
                 {% endif %}
             </td>

From f406ad75372dcbe1ca22ff62494c0d209d5e5557 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:21:15 +0200
Subject: [PATCH 140/493] only show version if present

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 5028760d0b..9fcd859369 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -43,7 +43,7 @@
             </td>
             <td>{{ plugin.author }}</td>
             <td>{{ plugin.pub_date }}</td>
-            <td>{{ plugin.version }}</td>
+            <td>{% if plugin.version %}{{ plugin.version }{% endif %}</td>
         </tr>
         {% endfor %}
     </tbody>

From 7ad9cf3b3db2564057455594427c8f983ba4fc70 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:22:37 +0200
Subject: [PATCH 141/493] human name in navbar

---
 InvenTree/templates/InvenTree/settings/navbar.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html
index 5e2a5a1811..9d9db36fe0 100644
--- a/InvenTree/templates/InvenTree/settings/navbar.html
+++ b/InvenTree/templates/InvenTree/settings/navbar.html
@@ -131,9 +131,9 @@
     {% plugin_list as pl_list %}
     {% for plugin_key, plugin in pl_list.items %}
         {% if plugin.registered_mixins %}
-            <li class='list-group-item' title='{{ plugin.plugin_name }}'>
+            <li class='list-group-item' title='{{ plugin.human_name }}'>
                 <a href='#' class='text-{{plugin.sign_color}} nav-toggle' id='select-plugin-{{plugin_key}}'>
-                    {{ plugin.plugin_name}}
+                    {{ plugin.human_name }}
                 </a>
             </li>
         {% endif %}

From a79dba5ed386e45a3b9f834f3c7909659369004c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:37:09 +0200
Subject: [PATCH 142/493] plugin information section remodel

---
 .../InvenTree/settings/plugin_settings.html   | 39 ++++++++++++++-----
 1 file changed, 30 insertions(+), 9 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 26c354be62..7e606cbf95 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -14,24 +14,48 @@
 
 <div class="row">
     <div class="col-md-6">
+        <h4>{% trans "Plugin information" %}</h4>
         <table class='table table-striped table-condensed'>
             <col width='25'>
             <tr>
-                <td><span class='fas fa-hashtag'></span></td>
-                <td>{% trans "Plugin Version" %}</td>
                 <td></td>
+                <td>{% trans "Name" %}</td>
+                <td>{{ plugin.human_name }}{% include "clip.html" %}</td>
             </tr>
             <tr>
-                <td><span class='fas fa-code-branch'></span></td>
-                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.hash }}{% include "clip.html" %}</td>
+                <td><span class='fas fa-user'></span></span></td>
+                <td>{% trans "Author" %}</td>
+                <td>{{ plugin.author }}{% include "clip.html" %}</td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-calendar-alt'></span></td>
+                <td>{% trans "Date" %}</td>
+                <td>{{ plugin.pub_date }}{% include "clip.html" %}</td>
+            </tr>
+            <tr>
+                <td><span class='fas fa-hashtag'></span></td>
+                <td>{% trans "Version" %}</td>
+                <td>{{ plugin.version }}{% include "clip.html" %}</td>
+            </tr>
+        </table>
+
+        <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
+    </div>
+    <div class="col-md-6">
+        <h4>{% trans "Code information" %}</h4>
+        <table class='table table-striped table-condensed'>
+            <col width='25'>
+            <tr>
+                <td><span class='fas fa-user'></span></td>
+                <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='fas fa-calendar-alt'></span></td>
                 <td>{% trans "Commit Date" %}</td><td>{{ plugin.commit.date }}{% include "clip.html" %}</td>
             </tr>
             <tr>
-                <td><span class='fas fa-user'></span></td>
-                <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
+                <td><span class='fas fa-code-branch'></span></td>
+                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.hash }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='fas fa-envelope'></span></td>
@@ -49,9 +73,6 @@
             </tr>
         </table>
     </div>
-    <div class="col-md-6">
-        <p>{% trans 'This information is pulled from the latest git commit for this plugin. It might not reflect official version numbers.' %}</p>
-    </div>
 </div>
 
 {% mixin_enabled plugin 'settings' as settings %}

From 950ae247a58e6b19c7305829e20c80eb812f48d3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:39:45 +0200
Subject: [PATCH 143/493] cleaner signature status

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 7e606cbf95..4a96e7e2e2 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -64,7 +64,7 @@
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
                 <td>{% trans "Commit Sign Status" %}</td>
-                <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.verified }}: {{ plugin.sign_state.msg }}</td>
+                <td class="bg-{{plugin.sign_color}}">{% if plugin.commit.verified %}{{ plugin.commit.verified }}: {% endif%}{{ plugin.sign_state.msg }}</td>
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td>

From 4dbf87da5647540dd13150430c208e695f92dcaf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:47:26 +0200
Subject: [PATCH 144/493] refactor

---
 .../InvenTree/settings/mixins/settings.html   | 14 ++++++++
 .../InvenTree/settings/mixins/urls.html       | 25 +++++++++++++
 .../InvenTree/settings/plugin_settings.html   | 35 ++-----------------
 3 files changed, 41 insertions(+), 33 deletions(-)
 create mode 100644 InvenTree/templates/InvenTree/settings/mixins/settings.html
 create mode 100644 InvenTree/templates/InvenTree/settings/mixins/urls.html

diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html
new file mode 100644
index 0000000000..9cd34639e8
--- /dev/null
+++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+{% load plugin_extras %}
+
+<h4>{% trans "Settings" %}</h4>
+{% plugin_settings plugin_key as plugin_settings %}
+
+<table class='table table-striped table-condensed'>
+    {% include "InvenTree/settings/header.html" %}
+    <tbody>
+    {% for setting in plugin_settings %}
+        {% include "InvenTree/settings/setting.html" with key=setting%}
+    {% endfor %}
+    </tbody>
+</table>
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/settings/mixins/urls.html b/InvenTree/templates/InvenTree/settings/mixins/urls.html
new file mode 100644
index 0000000000..1f317b5dc0
--- /dev/null
+++ b/InvenTree/templates/InvenTree/settings/mixins/urls.html
@@ -0,0 +1,25 @@
+{% load i18n %}
+{% load inventree_extras %}
+
+<h4>{% trans "URLs" %}</h4>
+{% define plugin.base_url as base %}
+<p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
+
+<table class='table table-striped table-condensed'>
+    <thead>
+        <tr>
+            <th>{% trans "Name" %}</th>
+            <th>{% trans "URL" %}</th>
+            <th></th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for key, entry in plugin.urlpatterns.reverse_dict.items %}{% if key %}
+        <tr>
+            <td>{{key}}</td>
+            <td>{{entry.1}}</td>
+            <td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
+        </tr>
+        {% endif %}{% endfor %}
+    </tbody>
+</table>
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 4a96e7e2e2..5f7e36e9af 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -77,43 +77,12 @@
 
 {% mixin_enabled plugin 'settings' as settings %}
 {% if settings %}
-<h4>{% trans "Settings" %}</h4>
-{% plugin_settings plugin_key as plugin_settings %}
-
-<table class='table table-striped table-condensed'>
-    {% include "InvenTree/settings/header.html" %}
-    <tbody>
-        {% for setting in plugin_settings %}
-            {% include "InvenTree/settings/setting.html" with key=setting%}
-        {% endfor %}
-    </tbody>
-</table>
+    {% include 'InvenTree/settings/mixins/settings.html' %}
 {% endif %}
 
 {% mixin_enabled plugin 'urls' as urls %}
 {% if urls %}
-<h4>{% trans "URLs" %}</h4>
-    {% define plugin.base_url as base %}
-    <p>{% blocktrans %}The Base-URL for this plugin is <a href="/{{ base }}" target="_blank"><strong>{{ base }}</strong></a>.{% endblocktrans %}</p>
-
-    <table class='table table-striped table-condensed'>
-        <thead>
-            <tr>
-                <th>{% trans "Name" %}</th>
-                <th>{% trans "URL" %}</th>
-                <th></th>
-            </tr>
-        </thead>
-        <tbody>
-            {% for key, entry in plugin.urlpatterns.reverse_dict.items %}{% if key %}
-            <tr>
-                <td>{{key}}</td>
-                <td>{{entry.1}}</td>
-                <td><a class="btn btn-primary btn-small" href="/{{ base }}{{entry.1}}" target="_blank">{% trans 'open in new tab' %}</a></td>
-            </tr>
-            {% endif %}{% endfor %}
-        </tbody>
-    </table>
+    {% include 'InvenTree/settings/mixins/urls.html' %}
 {% endif %}
 
 {% endblock %}

From c7a077fb33b0d227a5025182c01fbcae49a3ca75 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 22:59:07 +0200
Subject: [PATCH 145/493] more human datetimes

---
 InvenTree/plugins/integration.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index bf604ea391..430e0ba975 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -5,6 +5,7 @@ import logging
 import os
 import subprocess
 import inspect
+from datetime import datetime
 
 from django.conf.urls import url, include
 from django.conf import settings
@@ -301,6 +302,8 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         name = getattr(self, 'PUBLISH_DATE', None)
         if not name:
             name = self.commit.get('date')
+        else:
+            name = datetime.fromisoformat(name)
         if not name:
             name = _('No date found')
         return name
@@ -339,6 +342,10 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         self.commit = commit
         self.sign_state = sign_state
 
+        # process date
+        if self.commit['date']:
+            self.commit['date'] = datetime.fromisoformat(self.commit['date'])
+
         if sign_state.status == 0:
             self.sign_color = 'success'
         elif sign_state.status == 1:

From 2d5854e1e595777fea4feb6d921ad35c2d36db9f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 4 Oct 2021 23:00:07 +0200
Subject: [PATCH 146/493] fix nav error

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 9fcd859369..c2f01789df 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -43,7 +43,7 @@
             </td>
             <td>{{ plugin.author }}</td>
             <td>{{ plugin.pub_date }}</td>
-            <td>{% if plugin.version %}{{ plugin.version }{% endif %}</td>
+            <td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td>
         </tr>
         {% endfor %}
     </tbody>

From a42bf4983d959cbe4468821fed123e304ac3c846 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:27:08 +0200
Subject: [PATCH 147/493] App Mixin tests

---
 InvenTree/plugins/test_integration.py | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 0bfbbeb530..7a36f702ba 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
+from plugins.integration import IntegrationPluginBase, AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:
@@ -80,6 +80,16 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
         self.assertIsNone(self.mixin_nothing.urlpatterns)
 
 
+class AppMixinTest(BaseMixinDefinition, TestCase):
+    MIXIN_HUMAN_NAME = 'App registration'
+    MIXIN_NAME = 'app'
+    MIXIN_ENABLE_CHECK = 'has_app'
+
+    def test_function(self):
+        # test that this plugin is in settings
+        self.assertIn('plugin.sample', settings.INSTALLED_APPS)
+
+
 class NavigationMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'Navigation Links'
     MIXIN_NAME = 'navigation'

From 18630c0e0f08292157a8a900f8bd463ebb6835e8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:40:20 +0200
Subject: [PATCH 148/493] more tests for integrationbase

---
 .../plugins/samples/integration/sample.py     |  4 +-
 InvenTree/plugins/test_integration.py         | 48 +++++++++++++++++++
 2 files changed, 50 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugins/samples/integration/sample.py
index adfe74d7f6..381d91d1cd 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugins/samples/integration/sample.py
@@ -1,12 +1,12 @@
 """sample implementations for IntegrationPlugin"""
-from plugins.integration import SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
+from plugins.integration import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
 from django.conf.urls import url, include
 
 
-class SampleIntegrationPlugin(SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
+class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
     """
     An full integration plugin
     """
diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 7a36f702ba..86484dd95b 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -110,3 +110,51 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
             class NavigationCls(NavigationMixin, IntegrationPluginBase):
                 NAVIGATION = ['aa', 'aa']
             NavigationCls()
+
+
+class IntegrationPluginBaseTests(TestCase):
+    """ Tests for IntegrationPluginBase """
+
+    def setUp(self):
+        self.plugin = IntegrationPluginBase()
+
+        class SimpeIntegrationPluginBase(IntegrationPluginBase):
+            PLUGIN_NAME = 'SimplePlugin'
+
+        self.plugin_simple = SimpeIntegrationPluginBase()
+
+        class NameIntegrationPluginBase(IntegrationPluginBase):
+            PLUGIN_NAME = 'Aplugin'
+            PLUGIN_SLUG = 'a'
+            PLUGIN_TITLE = 'a titel'
+            PUBLISH_DATE = "1111.11.11"
+            VERSION = '1.2.3a'
+
+        self.plugin_name = NameIntegrationPluginBase()
+
+    def test_action_name(self):
+        """check the name definition possibilities"""
+        # plugin_name
+        self.assertEqual(self.plugin.plugin_name(), '')
+        self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
+        self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
+
+        # slug
+        self.assertEqual(self.plugin.slug, '')
+        self.assertEqual(self.plugin_simple.slug, 'simpleplugin')
+        self.assertEqual(self.plugin_name.slug, 'a')
+
+        # human_name
+        self.assertEqual(self.plugin.human_name, '')
+        self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
+        self.assertEqual(self.plugin_name.human_name, 'a titel')
+
+        # pub_date
+        self.assertEqual(self.plugin.pub_date, 'No date found')
+        self.assertEqual(self.plugin_simple.pub_date, 'No date found')
+        self.assertEqual(self.plugin_name.pub_date, "1111.11.11")
+
+        # version
+        self.assertEqual(self.plugin.version, None)
+        self.assertEqual(self.plugin_simple.version, None)
+        self.assertEqual(self.plugin_name.version, '1.2.3a')

From 486893cda5eb08b75ce65b6419a5638732785b70 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:42:42 +0200
Subject: [PATCH 149/493] type rec for title

---
 InvenTree/plugins/integration.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 430e0ba975..432fe1c3ac 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -256,6 +256,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     The IntegrationPluginBase class is used to integrate with 3rd party software
     """
     PLUGIN_SLUG = None
+    PLUGIN_TITLE = None
 
     AUTHOR = None
     PUBLISH_DATE = None

From d4eac2b477564aa158d806ac458f115fc63583ee Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:43:19 +0200
Subject: [PATCH 150/493] PEP fix

---
 InvenTree/plugins/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 86484dd95b..b9888e556b 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPluginBase, AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
+from plugins.integration import IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:

From bc0ee2a23573bffa547710e3d91f8ca0c9c31396 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:55:16 +0200
Subject: [PATCH 151/493] fix test path

---
 InvenTree/plugins/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index b9888e556b..5351820ddc 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -87,7 +87,7 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
 
     def test_function(self):
         # test that this plugin is in settings
-        self.assertIn('plugin.sample', settings.INSTALLED_APPS)
+        self.assertIn('plugins.samples.integration', settings.INSTALLED_APPS)
 
 
 class NavigationMixinTest(BaseMixinDefinition, TestCase):

From 9f558d0f69aac4eec4c52180548d6d4674c7376a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:56:33 +0200
Subject: [PATCH 152/493] fix mixin test

---
 InvenTree/plugins/test_integration.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 5351820ddc..b858572860 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,7 +4,7 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
-from plugins.integration import IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
+from plugins.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:
@@ -85,6 +85,11 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_NAME = 'app'
     MIXIN_ENABLE_CHECK = 'has_app'
 
+    def setUp(self):
+        class TestCls(AppMixin, IntegrationPluginBase):
+            pass
+        self.mixin = TestCls()
+
     def test_function(self):
         # test that this plugin is in settings
         self.assertIn('plugins.samples.integration', settings.INSTALLED_APPS)

From 98c93c59bbf0ccf31d4970559e58134122a4b0b6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 00:57:26 +0200
Subject: [PATCH 153/493] date depends on current git commit

---
 InvenTree/plugins/test_integration.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index b858572860..080d5fbe91 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -155,8 +155,6 @@ class IntegrationPluginBaseTests(TestCase):
         self.assertEqual(self.plugin_name.human_name, 'a titel')
 
         # pub_date
-        self.assertEqual(self.plugin.pub_date, 'No date found')
-        self.assertEqual(self.plugin_simple.pub_date, 'No date found')
         self.assertEqual(self.plugin_name.pub_date, "1111.11.11")
 
         # version

From 81757d5bbd0b6396a218e52dfbfbe77106a64190 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 01:16:25 +0200
Subject: [PATCH 154/493] fix iso format

---
 InvenTree/plugins/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 080d5fbe91..9eea22d4fc 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -132,7 +132,7 @@ class IntegrationPluginBaseTests(TestCase):
             PLUGIN_NAME = 'Aplugin'
             PLUGIN_SLUG = 'a'
             PLUGIN_TITLE = 'a titel'
-            PUBLISH_DATE = "1111.11.11"
+            PUBLISH_DATE = "1111-11-11"
             VERSION = '1.2.3a'
 
         self.plugin_name = NameIntegrationPluginBase()

From f1fd1d4da8c72d0fbe8952f1cc7492dbd44dffee Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 5 Oct 2021 22:21:15 +0200
Subject: [PATCH 155/493] set inventory on sitefix assertation

---
 InvenTree/plugins/test_integration.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index 9eea22d4fc..de75a48b45 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -4,6 +4,8 @@ from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
 
+from datetime import datetime
+
 from plugins.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
 
 
@@ -155,7 +157,7 @@ class IntegrationPluginBaseTests(TestCase):
         self.assertEqual(self.plugin_name.human_name, 'a titel')
 
         # pub_date
-        self.assertEqual(self.plugin_name.pub_date, "1111.11.11")
+        self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0))
 
         # version
         self.assertEqual(self.plugin.version, None)

From eeeb69ce12049ea4e3f04c2bc5e6c219cf7ece7b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 6 Oct 2021 10:54:57 +0200
Subject: [PATCH 156/493] website info for plugins

---
 InvenTree/plugins/integration.py                           | 7 +++++++
 InvenTree/plugins/test_integration.py                      | 6 ++++++
 InvenTree/templates/InvenTree/settings/plugin.html         | 4 ++++
 .../templates/InvenTree/settings/plugin_settings.html      | 7 +++++++
 4 files changed, 24 insertions(+)

diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugins/integration.py
index 432fe1c3ac..b55efb732a 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugins/integration.py
@@ -261,6 +261,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     AUTHOR = None
     PUBLISH_DATE = None
     VERSION = None
+    WEBSITE = None
 
     def __init__(self):
         super().__init__()
@@ -315,6 +316,12 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         name = getattr(self, 'VERSION', None)
         return name
 
+    @property
+    def website(self):
+        """returns website of plugin"""
+        name = getattr(self, 'WEBSITE', None)
+        return name
+
     # mixins
     def mixin(self, key):
         """check if mixin is registered"""
diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugins/test_integration.py
index de75a48b45..b391c81afd 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugins/test_integration.py
@@ -136,6 +136,7 @@ class IntegrationPluginBaseTests(TestCase):
             PLUGIN_TITLE = 'a titel'
             PUBLISH_DATE = "1111-11-11"
             VERSION = '1.2.3a'
+            WEBSITE = 'http://aa.bb/cc'
 
         self.plugin_name = NameIntegrationPluginBase()
 
@@ -163,3 +164,8 @@ class IntegrationPluginBaseTests(TestCase):
         self.assertEqual(self.plugin.version, None)
         self.assertEqual(self.plugin_simple.version, None)
         self.assertEqual(self.plugin_name.version, '1.2.3a')
+
+        # website
+        self.assertEqual(self.plugin.website, None)
+        self.assertEqual(self.plugin_simple.website, None)
+        self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index c2f01789df..69988f902b 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -40,6 +40,10 @@
                 </a>
                 {% endfor %}
                 {% endif %}
+
+                {% if plugin.website %}
+                <a href="{{ plugin.website }}"><i class="fas fa-globe"></i></a>
+                {% endif %}
             </td>
             <td>{{ plugin.author }}</td>
             <td>{{ plugin.pub_date }}</td>
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 5f7e36e9af..a8e7411ad9 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -37,6 +37,13 @@
                 <td>{% trans "Version" %}</td>
                 <td>{{ plugin.version }}{% include "clip.html" %}</td>
             </tr>
+            {% if plugin.website %}
+            <tr>
+                <td><span class='fas fa-globe'></span></td>
+                <td>{% trans "Website" %}</td>
+                <td>{{ plugin.website }}{% include "clip.html" %}</td>
+            </tr>
+            {% endif %}
         </table>
 
         <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>

From f07df107a93d76b6cd145b71a7109b1c8f404bbf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 6 Oct 2021 10:56:21 +0200
Subject: [PATCH 157/493] only show if info present

---
 InvenTree/templates/InvenTree/settings/plugin.html          | 1 +
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 2 ++
 2 files changed, 3 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 69988f902b..c517b72917 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -33,6 +33,7 @@
         <tr>
             <td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
                 {% define plugin.registered_mixins as mixin_list %}
+
                 {% if mixin_list %}
                 {% for mixin in mixin_list %}
                 <a class='nav-toggle' id='select-plugin-{{plugin_key}}'>
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index a8e7411ad9..5e516b0d63 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -32,11 +32,13 @@
                 <td>{% trans "Date" %}</td>
                 <td>{{ plugin.pub_date }}{% include "clip.html" %}</td>
             </tr>
+            {% if plugin.version %}
             <tr>
                 <td><span class='fas fa-hashtag'></span></td>
                 <td>{% trans "Version" %}</td>
                 <td>{{ plugin.version }}{% include "clip.html" %}</td>
             </tr>
+            {% endif %}
             {% if plugin.website %}
             <tr>
                 <td><span class='fas fa-globe'></span></td>

From dddd4370cf8cea3f7d20c900ab7baad0c4d954db Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 8 Oct 2021 22:08:09 +0200
Subject: [PATCH 158/493] refactor app

---
 InvenTree/InvenTree/api.py                       |  2 +-
 InvenTree/InvenTree/settings.py                  |  4 ++--
 InvenTree/barcodes/api.py                        |  2 +-
 InvenTree/{plugins => plugin}/__init__.py        |  0
 InvenTree/{plugins => plugin}/action.py          |  2 +-
 .../samples => plugin/builtin}/__init__.py       |  0
 .../builtin}/action/__init__.py                  |  0
 .../builtin}/action/simpleactionplugin.py        |  2 +-
 .../builtin}/action/test_samples_action.py       |  2 +-
 InvenTree/{plugins => plugin}/integration.py     |  2 +-
 InvenTree/{plugins => plugin}/loader.py          |  0
 InvenTree/{plugins => plugin}/plugin.py          |  0
 InvenTree/{plugins => plugin}/plugins.py         | 16 ++++++++--------
 .../integration => plugin/samples}/__init__.py   |  0
 InvenTree/plugin/samples/integration/__init__.py |  0
 .../samples/integration/another_sample.py        |  2 +-
 .../samples/integration/sample.py                |  2 +-
 .../integration/test_samples_integration.py      |  0
 InvenTree/{plugins => plugin}/test_action.py     |  2 +-
 .../{plugins => plugin}/test_integration.py      |  2 +-
 InvenTree/{plugins => plugin}/test_plugin.py     | 10 +++++-----
 plugins/__init__.py                              |  0
 22 files changed, 25 insertions(+), 25 deletions(-)
 rename InvenTree/{plugins => plugin}/__init__.py (100%)
 rename InvenTree/{plugins => plugin}/action.py (97%)
 rename InvenTree/{plugins/samples => plugin/builtin}/__init__.py (100%)
 rename InvenTree/{plugins/samples => plugin/builtin}/action/__init__.py (100%)
 rename InvenTree/{plugins/samples => plugin/builtin}/action/simpleactionplugin.py (93%)
 rename InvenTree/{plugins/samples => plugin/builtin}/action/test_samples_action.py (94%)
 rename InvenTree/{plugins => plugin}/integration.py (99%)
 rename InvenTree/{plugins => plugin}/loader.py (100%)
 rename InvenTree/{plugins => plugin}/plugin.py (100%)
 rename InvenTree/{plugins => plugin}/plugins.py (82%)
 rename InvenTree/{plugins/samples/integration => plugin/samples}/__init__.py (100%)
 create mode 100644 InvenTree/plugin/samples/integration/__init__.py
 rename InvenTree/{plugins => plugin}/samples/integration/another_sample.py (84%)
 rename InvenTree/{plugins => plugin}/samples/integration/sample.py (92%)
 rename InvenTree/{plugins => plugin}/samples/integration/test_samples_integration.py (100%)
 rename InvenTree/{plugins => plugin}/test_action.py (98%)
 rename InvenTree/{plugins => plugin}/test_integration.py (98%)
 rename InvenTree/{plugins => plugin}/test_plugin.py (89%)
 create mode 100644 plugins/__init__.py

diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py
index a006050694..e53a8b96cb 100644
--- a/InvenTree/InvenTree/api.py
+++ b/InvenTree/InvenTree/api.py
@@ -21,7 +21,7 @@ from .views import AjaxView
 from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
 from .status import is_worker_running
 
-from plugins import plugins as inventree_plugins
+from plugin import plugins as inventree_plugins
 
 
 logger = logging.getLogger("inventree")
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index bd69fafc93..baed15afa2 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -27,7 +27,7 @@ import yaml
 from django.utils.translation import gettext_lazy as _
 from django.contrib.messages import constants as messages
 
-from plugins import plugins as inventree_plugins
+from plugin import plugins as inventree_plugins
 
 
 def _is_true(x):
@@ -337,7 +337,7 @@ TEMPLATES = [
                 'django.template.loaders.cached.Loader', [
                     'django.template.loaders.app_directories.Loader',
                     'django.template.loaders.filesystem.Loader',
-                    'plugins.loader.PluginTemplateLoader',
+                    'plugin.loader.PluginTemplateLoader',
                 ])
             ],
         },
diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py
index dd596de794..38041096ab 100644
--- a/InvenTree/barcodes/api.py
+++ b/InvenTree/barcodes/api.py
@@ -13,7 +13,7 @@ from stock.models import StockItem
 from stock.serializers import StockItemSerializer
 
 from barcodes.barcode import hash_barcode
-from plugins.plugins import load_barcode_plugins
+from plugin.plugins import load_barcode_plugins
 
 
 class BarcodeScan(APIView):
diff --git a/InvenTree/plugins/__init__.py b/InvenTree/plugin/__init__.py
similarity index 100%
rename from InvenTree/plugins/__init__.py
rename to InvenTree/plugin/__init__.py
diff --git a/InvenTree/plugins/action.py b/InvenTree/plugin/action.py
similarity index 97%
rename from InvenTree/plugins/action.py
rename to InvenTree/plugin/action.py
index cc2872b6d2..5e36c22e74 100644
--- a/InvenTree/plugins/action.py
+++ b/InvenTree/plugin/action.py
@@ -3,7 +3,7 @@
 
 import logging
 
-import plugins.plugin as plugin
+import plugin.plugin as plugin
 
 
 logger = logging.getLogger("inventree")
diff --git a/InvenTree/plugins/samples/__init__.py b/InvenTree/plugin/builtin/__init__.py
similarity index 100%
rename from InvenTree/plugins/samples/__init__.py
rename to InvenTree/plugin/builtin/__init__.py
diff --git a/InvenTree/plugins/samples/action/__init__.py b/InvenTree/plugin/builtin/action/__init__.py
similarity index 100%
rename from InvenTree/plugins/samples/action/__init__.py
rename to InvenTree/plugin/builtin/action/__init__.py
diff --git a/InvenTree/plugins/samples/action/simpleactionplugin.py b/InvenTree/plugin/builtin/action/simpleactionplugin.py
similarity index 93%
rename from InvenTree/plugins/samples/action/simpleactionplugin.py
rename to InvenTree/plugin/builtin/action/simpleactionplugin.py
index def8aada8b..01a0829887 100644
--- a/InvenTree/plugins/samples/action/simpleactionplugin.py
+++ b/InvenTree/plugin/builtin/action/simpleactionplugin.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 """sample implementation for ActionPlugin"""
-from plugins.action import ActionPlugin
+from plugin.action import ActionPlugin
 
 
 class SimpleActionPlugin(ActionPlugin):
diff --git a/InvenTree/plugins/samples/action/test_samples_action.py b/InvenTree/plugin/builtin/action/test_samples_action.py
similarity index 94%
rename from InvenTree/plugins/samples/action/test_samples_action.py
rename to InvenTree/plugin/builtin/action/test_samples_action.py
index 595c3fa948..6406810894 100644
--- a/InvenTree/plugins/samples/action/test_samples_action.py
+++ b/InvenTree/plugin/builtin/action/test_samples_action.py
@@ -3,7 +3,7 @@
 from django.test import TestCase
 from django.contrib.auth import get_user_model
 
-from plugins.samples.action.simpleactionplugin import SimpleActionPlugin
+from plugin.builtin.action.simpleactionplugin import SimpleActionPlugin
 
 
 class SimpleActionPluginTests(TestCase):
diff --git a/InvenTree/plugins/integration.py b/InvenTree/plugin/integration.py
similarity index 99%
rename from InvenTree/plugins/integration.py
rename to InvenTree/plugin/integration.py
index b55efb732a..c47a6ba060 100644
--- a/InvenTree/plugins/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -12,7 +12,7 @@ from django.conf import settings
 from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 
-import plugins.plugin as plugin
+import plugin.plugin as plugin
 
 
 logger = logging.getLogger("inventree")
diff --git a/InvenTree/plugins/loader.py b/InvenTree/plugin/loader.py
similarity index 100%
rename from InvenTree/plugins/loader.py
rename to InvenTree/plugin/loader.py
diff --git a/InvenTree/plugins/plugin.py b/InvenTree/plugin/plugin.py
similarity index 100%
rename from InvenTree/plugins/plugin.py
rename to InvenTree/plugin/plugin.py
diff --git a/InvenTree/plugins/plugins.py b/InvenTree/plugin/plugins.py
similarity index 82%
rename from InvenTree/plugins/plugins.py
rename to InvenTree/plugin/plugins.py
index 142e22e6a0..0c1981ad59 100644
--- a/InvenTree/plugins/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -7,11 +7,11 @@ import pkgutil
 import logging
 
 # Action plugins
-import plugins.samples.action as action
-from plugins.action import ActionPlugin
+import plugin.builtin.action as action
+from plugin.action import ActionPlugin
 
-import plugins.samples.integration as integration
-from plugins.integration import IntegrationPluginBase
+import plugin.samples.integration as integration
+from plugin.integration import IntegrationPluginBase
 
 
 logger = logging.getLogger("inventree")
@@ -55,7 +55,7 @@ def get_plugins(pkg, baseclass):
     return plugins
 
 
-def load_plugins(name: str, module, cls):
+def load_plugins(name: str, cls, module=None):
     """general function to load a plugin class
 
     :param name: name of the plugin for logs
@@ -81,14 +81,14 @@ def load_action_plugins():
     """
     Return a list of all registered action plugins
     """
-    return load_plugins('action', action, ActionPlugin)
+    return load_plugins('action', ActionPlugin, module=action)
 
 
 def load_integration_plugins():
     """
     Return a list of all registered integration plugins
     """
-    return load_plugins('integration', integration, IntegrationPluginBase)
+    return load_plugins('integration', IntegrationPluginBase, module=integration)
 
 
 def load_barcode_plugins():
@@ -98,4 +98,4 @@ def load_barcode_plugins():
     from barcodes import plugins as BarcodePlugins
     from barcodes.barcode import BarcodePlugin
 
-    return load_plugins('barcode', BarcodePlugins, BarcodePlugin)
+    return load_plugins('barcode', BarcodePlugins, module=BarcodePlugin)
diff --git a/InvenTree/plugins/samples/integration/__init__.py b/InvenTree/plugin/samples/__init__.py
similarity index 100%
rename from InvenTree/plugins/samples/integration/__init__.py
rename to InvenTree/plugin/samples/__init__.py
diff --git a/InvenTree/plugin/samples/integration/__init__.py b/InvenTree/plugin/samples/integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/plugins/samples/integration/another_sample.py b/InvenTree/plugin/samples/integration/another_sample.py
similarity index 84%
rename from InvenTree/plugins/samples/integration/another_sample.py
rename to InvenTree/plugin/samples/integration/another_sample.py
index 800481dee4..5fe1daf30b 100644
--- a/InvenTree/plugins/samples/integration/another_sample.py
+++ b/InvenTree/plugin/samples/integration/another_sample.py
@@ -1,5 +1,5 @@
 """sample implementation for IntegrationPlugin"""
-from plugins.integration import IntegrationPluginBase, UrlsMixin
+from plugin.integration import IntegrationPluginBase, UrlsMixin
 
 
 class NoIntegrationPlugin(IntegrationPluginBase):
diff --git a/InvenTree/plugins/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py
similarity index 92%
rename from InvenTree/plugins/samples/integration/sample.py
rename to InvenTree/plugin/samples/integration/sample.py
index 381d91d1cd..b6be51823d 100644
--- a/InvenTree/plugins/samples/integration/sample.py
+++ b/InvenTree/plugin/samples/integration/sample.py
@@ -1,5 +1,5 @@
 """sample implementations for IntegrationPlugin"""
-from plugins.integration import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
+from plugin.integration import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
diff --git a/InvenTree/plugins/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
similarity index 100%
rename from InvenTree/plugins/samples/integration/test_samples_integration.py
rename to InvenTree/plugin/samples/integration/test_samples_integration.py
diff --git a/InvenTree/plugins/test_action.py b/InvenTree/plugin/test_action.py
similarity index 98%
rename from InvenTree/plugins/test_action.py
rename to InvenTree/plugin/test_action.py
index fb0b7b5aa4..2a9e4a9a37 100644
--- a/InvenTree/plugins/test_action.py
+++ b/InvenTree/plugin/test_action.py
@@ -2,7 +2,7 @@
 
 from django.test import TestCase
 
-from plugins.action import ActionPlugin
+from plugin.action import ActionPlugin
 
 
 class ActionPluginTests(TestCase):
diff --git a/InvenTree/plugins/test_integration.py b/InvenTree/plugin/test_integration.py
similarity index 98%
rename from InvenTree/plugins/test_integration.py
rename to InvenTree/plugin/test_integration.py
index b391c81afd..d865af86bd 100644
--- a/InvenTree/plugins/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -6,7 +6,7 @@ from django.conf.urls import url, include
 
 from datetime import datetime
 
-from plugins.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
+from plugin.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:
diff --git a/InvenTree/plugins/test_plugin.py b/InvenTree/plugin/test_plugin.py
similarity index 89%
rename from InvenTree/plugins/test_plugin.py
rename to InvenTree/plugin/test_plugin.py
index aa8aa92cfc..1bf60a0eb3 100644
--- a/InvenTree/plugins/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -3,11 +3,11 @@
 from django.test import TestCase
 from django.conf import settings
 
-import plugins.plugin
-import plugins.integration
-from plugins.samples.integration.sample import SampleIntegrationPlugin
-from plugins.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
-from plugins.plugins import load_integration_plugins  # , load_action_plugins, load_barcode_plugins
+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_integration_plugins  # , load_action_plugins, load_barcode_plugins
 import part.templatetags.plugin_extras as plugin_tags
 
 
diff --git a/plugins/__init__.py b/plugins/__init__.py
new file mode 100644
index 0000000000..e69de29bb2

From 207e6c29b958e002dfcf5a983f5d315905bb5d08 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 8 Oct 2021 22:24:07 +0200
Subject: [PATCH 159/493] fix tests

---
 InvenTree/plugin/test_plugin.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 1bf60a0eb3..4e3248cb26 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -14,9 +14,9 @@ import part.templatetags.plugin_extras as plugin_tags
 class InvenTreePluginTests(TestCase):
     """ Tests for InvenTreePlugin """
     def setUp(self):
-        self.plugin = plugins.plugin.InvenTreePlugin()
+        self.plugin = plugin.plugin.InvenTreePlugin()
 
-        class NamedPlugin(plugins.plugin.InvenTreePlugin):
+        class NamedPlugin(plugin.plugin.InvenTreePlugin):
             """a named plugin"""
             PLUGIN_NAME = 'abc123'
 

From 910fb4b6f71a37559b3e62748f66f452bbb09206 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 8 Oct 2021 22:28:33 +0200
Subject: [PATCH 160/493] fix wrong assertation path

---
 InvenTree/plugin/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index d865af86bd..32ebcc9e2f 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -94,7 +94,7 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
 
     def test_function(self):
         # test that this plugin is in settings
-        self.assertIn('plugins.samples.integration', settings.INSTALLED_APPS)
+        self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)
 
 
 class NavigationMixinTest(BaseMixinDefinition, TestCase):

From 3efda98b7d4d91606c6f851610cd63914416012b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 8 Oct 2021 22:56:40 +0200
Subject: [PATCH 161/493] fixed naming switchup

---
 InvenTree/plugin/plugins.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 0c1981ad59..43868ef662 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -98,4 +98,4 @@ def load_barcode_plugins():
     from barcodes import plugins as BarcodePlugins
     from barcodes.barcode import BarcodePlugin
 
-    return load_plugins('barcode', BarcodePlugins, module=BarcodePlugin)
+    return load_plugins('barcode', BarcodePlugin, module=BarcodePlugins)

From 63819e48712f03f03727964dbe350ab6106a75f0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 00:18:01 +0200
Subject: [PATCH 162/493] move plugins

---
 {plugins => InvenTree/plugins}/__init__.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename {plugins => InvenTree/plugins}/__init__.py (100%)

diff --git a/plugins/__init__.py b/InvenTree/plugins/__init__.py
similarity index 100%
rename from plugins/__init__.py
rename to InvenTree/plugins/__init__.py

From 8a56e70dfdf2b8bfe00f5104ed65d84353d1e8dd Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 00:22:55 +0200
Subject: [PATCH 163/493] create empty integration builtin

---
 .gitmodules                                      | 3 +++
 InvenTree/plugin/builtin/integration/__init__.py | 0
 InvenTree/plugin/plugins.py                      | 3 ++-
 InvenTree/plugins/ShopifyIntegrationPlugin       | 1 +
 4 files changed, 6 insertions(+), 1 deletion(-)
 create mode 100644 .gitmodules
 create mode 100644 InvenTree/plugin/builtin/integration/__init__.py
 create mode 160000 InvenTree/plugins/ShopifyIntegrationPlugin

diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..2eb40b3aec
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "inventree/plugins/ShopifyIntegrationPlugin"]
+	path = inventree/plugins/ShopifyIntegrationPlugin
+	url = https://github.com/matmair/ShopifyIntegrationPlugin
diff --git a/InvenTree/plugin/builtin/integration/__init__.py b/InvenTree/plugin/builtin/integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 43868ef662..e31c905550 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -10,7 +10,8 @@ import logging
 import plugin.builtin.action as action
 from plugin.action import ActionPlugin
 
-import plugin.samples.integration as integration
+# Integration
+import plugin.builtin.integration as integration
 from plugin.integration import IntegrationPluginBase
 
 
diff --git a/InvenTree/plugins/ShopifyIntegrationPlugin b/InvenTree/plugins/ShopifyIntegrationPlugin
new file mode 160000
index 0000000000..65c58c7e2d
--- /dev/null
+++ b/InvenTree/plugins/ShopifyIntegrationPlugin
@@ -0,0 +1 @@
+Subproject commit 65c58c7e2d133bf2098f91f6f202a6f6a2f09eb9

From fa9168d660065665ba6ad753ca256963d813eb4a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 20:43:46 +0200
Subject: [PATCH 164/493] preoad modules from external

---
 InvenTree/InvenTree/settings.py | 19 +++++++++++++++++++
 InvenTree/plugin/plugins.py     | 30 +++++++++++++++++++++++-------
 2 files changed, 42 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index baed15afa2..216908798f 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -19,6 +19,7 @@ import string
 import shutil
 import sys
 import pathlib
+import importlib
 from datetime import datetime
 
 import moneyed
@@ -28,6 +29,7 @@ from django.utils.translation import gettext_lazy as _
 from django.contrib.messages import constants as messages
 
 from plugin import plugins as inventree_plugins
+from plugin.integration import IntegrationPluginBase
 
 
 def _is_true(x):
@@ -659,6 +661,23 @@ MESSAGE_TAGS = {
 # Plugins
 PLUGIN_URL = 'plugin'
 
+PLUGIN_DIRS = [
+    'plugin.builtin',
+    'plugins',
+]
+
+# load samples if in debug mode
+if DEBUG:
+    PLUGIN_DIRS.append('plugin.samples')
+
+# collect all plugins from paths
+PLUGINS = []
+for plugin in PLUGIN_DIRS:
+    modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
+    if modules:
+        [PLUGINS.append(item) for item in modules]
+
+# collect integration plugins
 INTEGRATION_PLUGINS = []
 
 INTEGRATION_PLUGIN_SETTINGS = {}
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index e31c905550..e4ec361a38 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -6,12 +6,14 @@ import importlib
 import pkgutil
 import logging
 
+from django.conf import settings
+from django.core.exceptions import AppRegistryNotReady
+
 # Action plugins
 import plugin.builtin.action as action
 from plugin.action import ActionPlugin
 
 # Integration
-import plugin.builtin.integration as integration
 from plugin.integration import IntegrationPluginBase
 
 
@@ -23,9 +25,23 @@ def iter_namespace(pkg):
     return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
 
 
-def get_modules(pkg):
+def get_modules(pkg, recursive):
     """get all modules in a package"""
-    return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
+    if not recursive:
+        return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
+    
+    context = {}
+    for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
+        try:
+            module = loader.find_module(name).load_module(name)
+            pkg_names = getattr(module, '__all__', None)
+            for k, v in vars(module).items():
+                if not k.startswith('_') and (pkg_names is None or k in pkg_names):
+                    context[k] = v
+            context[name] = module
+        except AppRegistryNotReady:
+            pass
+    return [v for k, v in context.items()]
 
 
 def get_classes(module):
@@ -33,7 +49,7 @@ def get_classes(module):
     return inspect.getmembers(module, inspect.isclass)
 
 
-def get_plugins(pkg, baseclass):
+def get_plugins(pkg, baseclass, recursive):
     """
     Return a list of all modules under a given package.
 
@@ -43,7 +59,7 @@ def get_plugins(pkg, baseclass):
 
     plugins = []
 
-    modules = get_modules(pkg)
+    modules = get_modules(pkg, recursive)
 
     # Iterate through each module in the package
     for mod in modules:
@@ -67,7 +83,7 @@ def load_plugins(name: str, cls, module=None):
 
     logger.debug("Loading %s plugins", name)
 
-    plugins = get_plugins(module, cls)
+    plugins = get_plugins(module, cls) if module else settings.PLUGINS
 
     if len(plugins) > 0:
         logger.info("Discovered %i %s plugins:", len(plugins), name)
@@ -89,7 +105,7 @@ def load_integration_plugins():
     """
     Return a list of all registered integration plugins
     """
-    return load_plugins('integration', IntegrationPluginBase, module=integration)
+    return load_plugins('integration', IntegrationPluginBase)
 
 
 def load_barcode_plugins():

From 1dd41b0e2dbdd21e778aaa6c2185a4f8de100a41 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 20:57:30 +0200
Subject: [PATCH 165/493] default value for recursive

---
 InvenTree/plugin/plugins.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index e4ec361a38..d29b8093da 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -25,7 +25,7 @@ def iter_namespace(pkg):
     return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
 
 
-def get_modules(pkg, recursive):
+def get_modules(pkg, recursive: bool = False):
     """get all modules in a package"""
     if not recursive:
         return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
@@ -49,7 +49,7 @@ def get_classes(module):
     return inspect.getmembers(module, inspect.isclass)
 
 
-def get_plugins(pkg, baseclass, recursive):
+def get_plugins(pkg, baseclass, recursive: bool = False):
     """
     Return a list of all modules under a given package.
 

From 4b10c8ca1bcfc71ac5138771e47ac5de021ec361 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 22:10:26 +0200
Subject: [PATCH 166/493] load plugin samples also if in test mode

---
 InvenTree/InvenTree/settings.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 216908798f..fc47861b25 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -667,7 +667,7 @@ PLUGIN_DIRS = [
 ]
 
 # load samples if in debug mode
-if DEBUG:
+if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
 # collect all plugins from paths

From 269bca27e9a8fa106cba1e36e9dffc9a4d73c32d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 23:16:38 +0200
Subject: [PATCH 167/493] more tests

---
 InvenTree/plugin/test_integration.py | 27 ++++++++++++++++++++++++++-
 1 file changed, 26 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 32ebcc9e2f..fec87322ef 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -22,7 +22,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_NAME = 'settings'
     MIXIN_ENABLE_CHECK = 'has_settings'
 
-    TEST_SETTINGS = {'setting1': [1, 2, 3]}
+    TEST_SETTINGS = {'setting1': {'default': '123',}}
 
     def setUp(self):
         class SettingsCls(SettingsMixin, IntegrationPluginBase):
@@ -45,6 +45,15 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         self.assertIsNone(self.mixin_nothing.settings)
         self.assertIsNone(self.mixin_nothing.settingspatterns)
 
+        # calling settings
+        # not existing
+        self.assertEqual(self.mixin.get_setting('ABCD'), '')
+        self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
+        # right setting
+        self.assertEqual(self.mixin.get_setting('setting1'), '123')
+        # no setting
+        self.assertEqual(self.mixin_nothing.get_setting(), '')
+
 
 class UrlsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'URLs'
@@ -81,6 +90,9 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
         self.assertIsNone(self.mixin_nothing.urls)
         self.assertIsNone(self.mixin_nothing.urlpatterns)
 
+        # internal name
+        self.assertEqual(self.mixin.internal_name,  f'plugin:{self.mixin.slug}:')
+
 
 class AppMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'App registration'
@@ -107,8 +119,13 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
             NAVIGATION = [
                 {'name': 'aa', 'link': 'plugin:test:test_view'},
             ]
+            NAVIGATION_TAB_NAME = 'abcd1'
         self.mixin = NavigationCls()
 
+        class NothingNavigationCls(NavigationMixin, IntegrationPluginBase):
+            pass
+        self.nothing_mixin = NothingNavigationCls()
+
     def test_function(self):
         # check right configuration
         self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ])
@@ -118,6 +135,10 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
                 NAVIGATION = ['aa', 'aa']
             NavigationCls()
 
+        # navigation name
+        self.assertEqual(self.mixin.navigation_name, 'abcd1')
+        self.assertEqual(self.nothing_mixin.navigation_name, '')
+
 
 class IntegrationPluginBaseTests(TestCase):
     """ Tests for IntegrationPluginBase """
@@ -135,6 +156,7 @@ class IntegrationPluginBaseTests(TestCase):
             PLUGIN_SLUG = 'a'
             PLUGIN_TITLE = 'a titel'
             PUBLISH_DATE = "1111-11-11"
+            AUTHOR = 'AA BB'
             VERSION = '1.2.3a'
             WEBSITE = 'http://aa.bb/cc'
 
@@ -157,6 +179,9 @@ class IntegrationPluginBaseTests(TestCase):
         self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
         self.assertEqual(self.plugin_name.human_name, 'a titel')
 
+        # author
+        self.assertEqual(self.plugin_name.author, 'AA BB')
+
         # pub_date
         self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0))
 

From a32486cfbdb2d9f9edc2efdc4c14dc7162455d81 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 23:19:01 +0200
Subject: [PATCH 168/493] PEP fix

---
 InvenTree/plugin/test_integration.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index fec87322ef..6a9eda49c0 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -22,7 +22,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_NAME = 'settings'
     MIXIN_ENABLE_CHECK = 'has_settings'
 
-    TEST_SETTINGS = {'setting1': {'default': '123',}}
+    TEST_SETTINGS = {'setting1': {'default': '123', }}
 
     def setUp(self):
         class SettingsCls(SettingsMixin, IntegrationPluginBase):
@@ -91,7 +91,7 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
         self.assertIsNone(self.mixin_nothing.urlpatterns)
 
         # internal name
-        self.assertEqual(self.mixin.internal_name,  f'plugin:{self.mixin.slug}:')
+        self.assertEqual(self.mixin.internal_name, f'plugin:{self.mixin.slug}:')
 
 
 class AppMixinTest(BaseMixinDefinition, TestCase):

From b29650f3af3b8d29f6cc7370c3be549677f151cc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 9 Oct 2021 23:54:55 +0200
Subject: [PATCH 169/493] fixxed settings dict

---
 InvenTree/plugin/test_integration.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 6a9eda49c0..7b5a774568 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -22,7 +22,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_NAME = 'settings'
     MIXIN_ENABLE_CHECK = 'has_settings'
 
-    TEST_SETTINGS = {'setting1': {'default': '123', }}
+    TEST_SETTINGS = {'SETTING1': {'default': '123', }}
 
     def setUp(self):
         class SettingsCls(SettingsMixin, IntegrationPluginBase):
@@ -50,7 +50,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         self.assertEqual(self.mixin.get_setting('ABCD'), '')
         self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
         # right setting
-        self.assertEqual(self.mixin.get_setting('setting1'), '123')
+        self.assertEqual(self.mixin.get_setting('SETTING1'), '123')
         # no setting
         self.assertEqual(self.mixin_nothing.get_setting(), '')
 

From c67f2aa2d743e29f4ee127c51da437242f68df17 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 10 Oct 2021 00:21:02 +0200
Subject: [PATCH 170/493] set_setting fnc + fixed settings test

---
 InvenTree/plugin/integration.py      | 13 ++++++++++++-
 InvenTree/plugin/test_integration.py |  7 ++++++-
 2 files changed, 18 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index c47a6ba060..d3e521c21b 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -88,12 +88,23 @@ class SettingsMixin:
             return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.settings.items()}
         return None
 
+    def _setting_name(self, key):
+        """get global name of setting"""
+        return f'PLUGIN_{self.slug.upper()}_{key}'
+
     def get_setting(self, key):
         """
         get plugin setting by key
         """
         from common.models import InvenTreeSetting
-        return InvenTreeSetting.get_setting(f'PLUGIN_{self.slug.upper()}_{key}')
+        return InvenTreeSetting.get_setting(self._setting_name(key))
+
+    def set_setting(self, key, value, user):
+        """
+        set plugin setting by key
+        """
+        from common.models import InvenTreeSetting
+        return InvenTreeSetting.set_setting(self._setting_name(key), value, user)
 
 
 class UrlsMixin:
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 7b5a774568..247992f3b7 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -3,6 +3,7 @@
 from django.test import TestCase
 from django.conf import settings
 from django.conf.urls import url, include
+from django.contrib.auth import get_user_model
 
 from datetime import datetime
 
@@ -33,6 +34,9 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
             pass
         self.mixin_nothing = NoSettingsCls()
 
+        user = get_user_model()
+        self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
+
     def test_function(self):
         # settings variable
         self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
@@ -50,7 +54,8 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         self.assertEqual(self.mixin.get_setting('ABCD'), '')
         self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
         # right setting
-        self.assertEqual(self.mixin.get_setting('SETTING1'), '123')
+        self.assertEqual(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_setting(), '')
 

From b2b0113ac1eac0d2e74e362a02254d571e7cedcb Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 10 Oct 2021 00:47:15 +0200
Subject: [PATCH 171/493] user needs to be staff

---
 InvenTree/plugin/test_integration.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 247992f3b7..bce51965a6 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -36,6 +36,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
 
         user = get_user_model()
         self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password')
+        self.test_user.is_staff = True
 
     def test_function(self):
         # settings variable
@@ -54,7 +55,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         self.assertEqual(self.mixin.get_setting('ABCD'), '')
         self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
         # right setting
-        self.assertEqual(self.mixin.set_setting('SETTING1'), '12345', self.test_user)
+        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_setting(), '')

From dde9810f2ba75d99a0dd6f8a87e7847f6b6bcf06 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 10 Oct 2021 00:47:39 +0200
Subject: [PATCH 172/493] no key => failing test

---
 InvenTree/plugin/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index bce51965a6..53652b7a6f 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -58,7 +58,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         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_setting(), '')
+        self.assertEqual(self.mixin_nothing.get_setting(''), '')
 
 
 class UrlsMixinTest(BaseMixinDefinition, TestCase):

From 41ce66df6e0f1f8ad8852c280ec95ef40abbbe36 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 00:32:48 +0200
Subject: [PATCH 173/493] add navigation setting

---
 InvenTree/common/models.py                         | 6 ++++++
 InvenTree/templates/InvenTree/settings/plugin.html | 8 ++++++++
 InvenTree/templates/navbar.html                    | 3 +++
 3 files changed, 17 insertions(+)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 2f32831c8e..848e4e9b0a 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -855,6 +855,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'choices': settings_group_options
         },
 
+        'ENABLE_PLUGINS_NAVIGATION': {
+            'name': _('Enable navigation integration'),
+            'description': _('Enable plugins to integrate into navigation'),
+            'default': False,
+            'validator': bool,
+        },
         **settings.INTEGRATION_PLUGIN_SETTINGS,
     }
 
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index c517b72917..8b4f03bbea 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -12,6 +12,14 @@
 
 {% block content %}
 
+
+<table class='table table-striped table-condensed'>
+    {% include "InvenTree/settings/header.html" %}
+    <tbody>
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
+    </tbody>
+</table>
+
 <h4>{% trans "Plugin list" %}</h4>
 
 <table class='table table-striped table-condensed'>
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index 493151dbe7..c2f6038041 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -4,6 +4,7 @@
 {% load i18n %}
 
 {% settings_value 'BARCODE_ENABLE' as barcodes %}
+{% settings_value 'ENABLE_PLUGINS_NAVIGATION' as plugin_nav %}
 
 <nav class="navbar navbar-xs navbar-default navbar-fixed-top ">
   <div class="container-fluid">
@@ -49,6 +50,7 @@
         </li>
         {% endif %}
 
+        {% if plugin_nav %}
         {% plugin_list as pl_list %}
         {% for plugin_key, plugin in pl_list.items %}
           {% mixin_enabled plugin 'navigation' as navigation %}
@@ -64,6 +66,7 @@
           </li>
           {% endif %}
         {% endfor %}
+        {% endif %}
 
       </ul>
       <ul class="nav navbar-nav navbar-right">

From 952e7e4554dbfcf5c55d2127897dc4181819c2b3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 00:34:15 +0200
Subject: [PATCH 174/493] add url setting

---
 InvenTree/InvenTree/urls.py                        | 10 ++++++----
 InvenTree/common/models.py                         |  7 ++++++-
 InvenTree/templates/InvenTree/settings/plugin.html |  1 +
 3 files changed, 13 insertions(+), 5 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 1b92757fda..2ed421d2ac 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -43,6 +43,7 @@ from .views import AppearanceSelectView, SettingCategorySelectView
 from .views import DynamicJsView
 
 from common.views import SettingEdit, UserSettingEdit
+from common.models import InvenTreeSetting
 
 from .api import InfoView, NotFoundView
 from .api import ActionPluginView
@@ -125,11 +126,12 @@ translated_javascript_urls = [
 ]
 
 # Integration plugin urls
-integration_plugins = settings.INTEGRATION_PLUGINS
 interation_urls = []
-for plugin in integration_plugins:
-    if plugin.mixin_enabled('urls'):
-        interation_urls.append(plugin.urlpatterns)
+if InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
+    integration_plugins = settings.INTEGRATION_PLUGINS
+    for plugin in integration_plugins:
+        if plugin.mixin_enabled('urls'):
+            interation_urls.append(plugin.urlpatterns)
 
 urlpatterns = [
     url(r'^part/', include(part_urls)),
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 848e4e9b0a..8bdc48bbb1 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -854,7 +854,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'default': '',
             'choices': settings_group_options
         },
-
+        'ENABLE_PLUGINS_URL': {
+            'name': _('Enable URL integration'),
+            'description': _('Enable plugins to add URL routes'),
+            'default': False,
+            'validator': bool,
+        },
         'ENABLE_PLUGINS_NAVIGATION': {
             'name': _('Enable navigation integration'),
             'description': _('Enable plugins to integrate into navigation'),
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 8b4f03bbea..19ab02761a 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -16,6 +16,7 @@
 <table class='table table-striped table-condensed'>
     {% include "InvenTree/settings/header.html" %}
     <tbody>
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
     </tbody>
 </table>

From c16c26c4967382a11487f038215a8954aca06174 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 04:17:48 +0200
Subject: [PATCH 175/493] setting to control plugin settings ingestion

---
 InvenTree/InvenTree/settings.py               |  6 +-----
 InvenTree/common/models.py                    |  7 ++++++-
 InvenTree/plugin/apps.py                      | 19 +++++++++++++++++++
 .../templates/InvenTree/settings/plugin.html  |  1 +
 4 files changed, 27 insertions(+), 6 deletions(-)
 create mode 100644 InvenTree/plugin/apps.py

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index e1dabedc49..ba17ed6815 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -266,6 +266,7 @@ INSTALLED_APPS = [
     'report.apps.ReportConfig',
     'stock.apps.StockConfig',
     'users.apps.UsersConfig',
+    'plugin.apps.PluginConfig',
     'InvenTree.apps.InvenTreeConfig',       # InvenTree app runs last
 
     # Third part add-ons
@@ -764,11 +765,6 @@ for plugin in inventree_plugins.load_integration_plugins():
 
     INTEGRATION_PLUGINS.append(plugin)
     INTEGRATION_PLUGIN_LIST[plugin.slug] = plugin
-    if plugin.mixin_enabled('settings'):
-        plugin_setting = plugin.settingspatterns
-
-        INTEGRATION_PLUGIN_SETTING[plugin.slug] = plugin_setting
-        INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
 
     if plugin.mixin_enabled('app'):
         plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(BASE_DIR).parts)
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 8bdc48bbb1..1415319b58 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -866,7 +866,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'default': False,
             'validator': bool,
         },
-        **settings.INTEGRATION_PLUGIN_SETTINGS,
+        'ENABLE_PLUGINS_SETTING': {
+            'name': _('Enable setting integration'),
+            'description': _('Enable plugins to integrate into inventree settings'),
+            'default': False,
+            'validator': bool,
+        },
     }
 
     class Meta:
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
new file mode 100644
index 0000000000..197834c373
--- /dev/null
+++ b/InvenTree/plugin/apps.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+from django.conf import settings
+
+
+class PluginConfig(AppConfig):
+    name = 'plugin'
+
+    def ready(self):
+        from common.models import InvenTreeSetting
+
+        if InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+            for slug, plugin in settings.INTEGRATION_PLUGIN_LIST.items():
+                if plugin.mixin_enabled('settings'):
+                    plugin_setting = plugin.settingspatterns
+                    settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
+                    settings.INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 19ab02761a..69c9bb719a 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -18,6 +18,7 @@
     <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_SETTING"%}
     </tbody>
 </table>
 

From 99e4b6f6a54669904e0c7a31dfc45f8b84eb3b93 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 17:47:05 +0200
Subject: [PATCH 176/493] settting to control app loading

---
 InvenTree/InvenTree/settings.py               |  6 +----
 InvenTree/common/models.py                    |  6 +++++
 InvenTree/plugin/apps.py                      | 26 ++++++++++++++++++-
 .../templates/InvenTree/settings/plugin.html  |  1 +
 4 files changed, 33 insertions(+), 6 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index ba17ed6815..e5458f978d 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -759,13 +759,9 @@ INTEGRATION_PLUGINS = []
 INTEGRATION_PLUGIN_SETTINGS = {}
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_PLUGIN_LIST = {}
+INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
 
 for plugin in inventree_plugins.load_integration_plugins():
     plugin = plugin()
-
     INTEGRATION_PLUGINS.append(plugin)
     INTEGRATION_PLUGIN_LIST[plugin.slug] = plugin
-
-    if plugin.mixin_enabled('app'):
-        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(BASE_DIR).parts)
-        INSTALLED_APPS += [plugin_path]
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 1415319b58..3ddad8ff5d 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -872,6 +872,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'default': False,
             'validator': bool,
         },
+        'ENABLE_PLUGINS_APP': {
+            'name': _('Enable app integration'),
+            'description': _('Enable plugins to add apps'),
+            'default': False,
+            'validator': bool,
+        },
     }
 
     class Meta:
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 197834c373..f4f2966d0a 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -1,7 +1,10 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
+import pathlib
+from typing import OrderedDict
+from allauth.socialaccount import app_settings
 
-from django.apps import AppConfig
+from django.apps import AppConfig, apps
 from django.conf import settings
 
 
@@ -11,9 +14,30 @@ class PluginConfig(AppConfig):
     def ready(self):
         from common.models import InvenTreeSetting
 
+        # if plugin settings are enabled enhance the settings
         if InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
             for slug, plugin in settings.INTEGRATION_PLUGIN_LIST.items():
                 if plugin.mixin_enabled('settings'):
                     plugin_setting = plugin.settingspatterns
                     settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
                     settings.INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
+
+        # if plugin apps are enabled
+        if (not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
+            settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
+            apps_changed = False
+
+            # add them to the INSTALLED_APPS
+            for slug, plugin in settings.INTEGRATION_PLUGIN_LIST.items():
+                if plugin.mixin_enabled('app'):
+                    plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+                    settings.INSTALLED_APPS += [plugin_path]
+                    apps_changed = True
+
+            # if apps were changed reload
+            # TODO this is a bit jankey to be honest
+            if apps_changed:
+                apps.app_configs = OrderedDict()
+                apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+                apps.clear_cache()
+                apps.populate(settings.INSTALLED_APPS)
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 69c9bb719a..05ebd27816 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -19,6 +19,7 @@
         {% 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_SETTING"%}
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
     </tbody>
 </table>
 

From 9ac6bf26e55d38024db2242e6c28427599da5f32 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 17:50:30 +0200
Subject: [PATCH 177/493] added warning

---
 InvenTree/templates/InvenTree/settings/plugin.html | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 05ebd27816..7324a67e01 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -12,6 +12,9 @@
 
 {% block content %}
 
+<div class="alert alert-danger" role="alert">
+    {% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under usage." %}
+</div>
 
 <table class='table table-striped table-condensed'>
     {% include "InvenTree/settings/header.html" %}

From 81b2a1e9c9d17dc5e2a52e45d4965dbbe20d3bdc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 17:52:58 +0200
Subject: [PATCH 178/493] fix warning block

---
 InvenTree/templates/InvenTree/settings/plugin.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 7324a67e01..acacbdff4d 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -12,8 +12,8 @@
 
 {% block content %}
 
-<div class="alert alert-danger" role="alert">
-    {% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under usage." %}
+<div class='alert alert-block alert-danger'>
+    {% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %}
 </div>
 
 <table class='table table-striped table-condensed'>

From 77312b031a4d4a0b2692e28a94533eb2fb7fca6b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 16 Oct 2021 17:54:34 +0200
Subject: [PATCH 179/493] PEP fixes

---
 InvenTree/InvenTree/settings.py | 1 -
 InvenTree/plugin/apps.py        | 1 -
 2 files changed, 2 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index e5458f978d..a73d2952de 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -18,7 +18,6 @@ import random
 import string
 import shutil
 import sys
-import pathlib
 import importlib
 from datetime import datetime
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index f4f2966d0a..b620b91b80 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -2,7 +2,6 @@
 from __future__ import unicode_literals
 import pathlib
 from typing import OrderedDict
-from allauth.socialaccount import app_settings
 
 from django.apps import AppConfig, apps
 from django.conf import settings

From dfe10a417b0d59cfb2998ef742575b13f2a222d0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 04:24:04 +0200
Subject: [PATCH 180/493] fix app settings

---
 InvenTree/plugin/apps.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b620b91b80..3144a7bfe2 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -20,6 +20,7 @@ class PluginConfig(AppConfig):
                     plugin_setting = plugin.settingspatterns
                     settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
                     settings.INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
+                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
 
         # if plugin apps are enabled
         if (not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):

From cad744e40b4a75eab04f4f650c1fb1d1b3b3fc49 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 04:27:19 +0200
Subject: [PATCH 181/493] remove unneeded setting

---
 InvenTree/InvenTree/settings.py | 1 -
 InvenTree/plugin/apps.py        | 1 -
 2 files changed, 2 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index a73d2952de..5c67dc69f5 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -755,7 +755,6 @@ for plugin in PLUGIN_DIRS:
 # collect integration plugins
 INTEGRATION_PLUGINS = []
 
-INTEGRATION_PLUGIN_SETTINGS = {}
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_PLUGIN_LIST = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 3144a7bfe2..639f43bd55 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -19,7 +19,6 @@ class PluginConfig(AppConfig):
                 if plugin.mixin_enabled('settings'):
                     plugin_setting = plugin.settingspatterns
                     settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
-                    settings.INTEGRATION_PLUGIN_SETTINGS.update(plugin_setting)
                     InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
 
         # if plugin apps are enabled

From 48abd3cf79078863d827dde7950625571d25a5ed Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 04:35:24 +0200
Subject: [PATCH 182/493] remove unneeded settings

---
 InvenTree/InvenTree/settings.py | 3 ---
 InvenTree/InvenTree/urls.py     | 3 +--
 InvenTree/plugin/loader.py      | 2 +-
 3 files changed, 2 insertions(+), 6 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 5c67dc69f5..0eaa5b592c 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -753,13 +753,10 @@ for plugin in PLUGIN_DIRS:
         [PLUGINS.append(item) for item in modules]
 
 # collect integration plugins
-INTEGRATION_PLUGINS = []
-
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_PLUGIN_LIST = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
 
 for plugin in inventree_plugins.load_integration_plugins():
     plugin = plugin()
-    INTEGRATION_PLUGINS.append(plugin)
     INTEGRATION_PLUGIN_LIST[plugin.slug] = plugin
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 2ed421d2ac..5145442f23 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -128,8 +128,7 @@ translated_javascript_urls = [
 # Integration plugin urls
 interation_urls = []
 if InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
-    integration_plugins = settings.INTEGRATION_PLUGINS
-    for plugin in integration_plugins:
+    for plugin in settings.INTEGRATION_PLUGIN_LIST.values():
         if plugin.mixin_enabled('urls'):
             interation_urls.append(plugin.urlpatterns)
 
diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py
index 82680d534d..5331418b24 100644
--- a/InvenTree/plugin/loader.py
+++ b/InvenTree/plugin/loader.py
@@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
     def get_dirs(self):
         dirname = 'templates'
         template_dirs = []
-        for plugin in settings.INTEGRATION_PLUGINS:
+        for plugin in settings.INTEGRATION_PLUGIN_LIST.values():
             new_path = Path(plugin.path) / dirname
             if Path(new_path).is_dir():
                 template_dirs.append(new_path)

From 279ed78119652ca25862eae2ea4d163d60239f4c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 04:47:10 +0200
Subject: [PATCH 183/493] refactor

---
 InvenTree/InvenTree/settings.py              | 4 ++--
 InvenTree/InvenTree/urls.py                  | 2 +-
 InvenTree/part/templatetags/plugin_extras.py | 2 +-
 InvenTree/plugin/apps.py                     | 7 +++++--
 InvenTree/plugin/loader.py                   | 2 +-
 InvenTree/plugin/test_plugin.py              | 2 +-
 6 files changed, 11 insertions(+), 8 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 0eaa5b592c..a4e7870143 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -753,10 +753,10 @@ for plugin in PLUGIN_DIRS:
         [PLUGINS.append(item) for item in modules]
 
 # collect integration plugins
+INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGIN_SETTING = {}
-INTEGRATION_PLUGIN_LIST = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
 
 for plugin in inventree_plugins.load_integration_plugins():
     plugin = plugin()
-    INTEGRATION_PLUGIN_LIST[plugin.slug] = plugin
+    INTEGRATION_PLUGINS[plugin.slug] = plugin
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 5145442f23..d0b7d1cedf 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -128,7 +128,7 @@ translated_javascript_urls = [
 # Integration plugin urls
 interation_urls = []
 if InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
-    for plugin in settings.INTEGRATION_PLUGIN_LIST.values():
+    for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):
             interation_urls.append(plugin.urlpatterns)
 
diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
index 6f214340f7..e0592f8f28 100644
--- a/InvenTree/part/templatetags/plugin_extras.py
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -12,7 +12,7 @@ register = template.Library()
 @register.simple_tag()
 def plugin_list(*args, **kwargs):
     """ Return a list of all installed integration plugins """
-    return djangosettings.INTEGRATION_PLUGIN_LIST
+    return djangosettings.INTEGRATION_PLUGINS
 
 
 @register.simple_tag()
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 639f43bd55..996aa4c8a4 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -12,13 +12,16 @@ class PluginConfig(AppConfig):
 
     def ready(self):
         from common.models import InvenTreeSetting
+        plugins = settings.INTEGRATION_PLUGINS.items()
 
         # if plugin settings are enabled enhance the settings
         if InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
-            for slug, plugin in settings.INTEGRATION_PLUGIN_LIST.items():
+            for slug, plugin in plugins:
                 if plugin.mixin_enabled('settings'):
                     plugin_setting = plugin.settingspatterns
                     settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
+
+                    # Add to settings dir
                     InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
 
         # if plugin apps are enabled
@@ -27,7 +30,7 @@ class PluginConfig(AppConfig):
             apps_changed = False
 
             # add them to the INSTALLED_APPS
-            for slug, plugin in settings.INTEGRATION_PLUGIN_LIST.items():
+            for slug, plugin in plugins:
                 if plugin.mixin_enabled('app'):
                     plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
                     settings.INSTALLED_APPS += [plugin_path]
diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py
index 5331418b24..21938f81c6 100644
--- a/InvenTree/plugin/loader.py
+++ b/InvenTree/plugin/loader.py
@@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
     def get_dirs(self):
         dirname = 'templates'
         template_dirs = []
-        for plugin in settings.INTEGRATION_PLUGIN_LIST.values():
+        for plugin in settings.INTEGRATION_PLUGINS.values():
             new_path = Path(plugin.path) / dirname
             if Path(new_path).is_dir():
                 template_dirs.append(new_path)
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 4e3248cb26..8ff47987b3 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -57,7 +57,7 @@ class PluginTagTests(TestCase):
 
     def test_tag_plugin_list(self):
         """test that all plugins are listed"""
-        self.assertEqual(plugin_tags.plugin_list(), settings.INTEGRATION_PLUGIN_LIST)
+        self.assertEqual(plugin_tags.plugin_list(), settings.INTEGRATION_PLUGINS)
 
     def test_tag_plugin_settings(self):
         """check all plugins are listed"""

From d3a4aede294ab6d4c2dd5a772403b5cc8cf76ff6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:32:06 +0200
Subject: [PATCH 184/493] do not load external plugins for tests

---
 InvenTree/InvenTree/settings.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index a4e7870143..52f4eb3dca 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -736,10 +736,10 @@ MARKDOWNIFY_BLEACH = False
 # Plugins
 PLUGIN_URL = 'plugin'
 
-PLUGIN_DIRS = [
-    'plugin.builtin',
-    'plugins',
-]
+PLUGIN_DIRS = ['plugin.builtin', ]
+
+if not TESTING:
+    PLUGIN_DIRS.append('plugins')
 
 # load samples if in debug mode
 if DEBUG or TESTING:

From 56d198edb5341a57377ccd0d811e3243aeb97f81 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:40:06 +0200
Subject: [PATCH 185/493] mabe migrate first

---
 .github/workflows/mysql.yaml | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/mysql.yaml b/.github/workflows/mysql.yaml
index f0eb0efd7f..058b0fd9c7 100644
--- a/.github/workflows/mysql.yaml
+++ b/.github/workflows/mysql.yaml
@@ -55,7 +55,9 @@ jobs:
           pip3 install mysqlclient
           invoke install
       - name: Run Tests
-        run: invoke test
+        run: |
+          invoke migrate
+          invoke test
       - name: Data Import Export
         run: |
           invoke migrate

From 487ac594bb9cbe44228eac1408fa8437177f5c07 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:50:06 +0200
Subject: [PATCH 186/493] testing save navigation checks

---
 InvenTree/part/templatetags/plugin_extras.py | 9 +++++++++
 InvenTree/templates/navbar.html              | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
index e0592f8f28..590b89d7ae 100644
--- a/InvenTree/part/templatetags/plugin_extras.py
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -5,6 +5,8 @@
 from django.conf import settings as djangosettings
 from django import template
 
+from common.models import InvenTreeSetting
+
 
 register = template.Library()
 
@@ -25,3 +27,10 @@ def plugin_settings(plugin, *args, **kwargs):
 def mixin_enabled(plugin, key, *args, **kwargs):
     """ Return if the mixin is existant and configured in the plugin """
     return plugin.mixin_enabled(key)
+
+@register.simple_tag()
+def navigation_enabled(*args, **kwargs):
+    """Return if plugin navigation is enabled"""
+    if djangosettings.TESTING:
+        return True
+    return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION')
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index c2f6038041..ad49ecf401 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -4,7 +4,7 @@
 {% load i18n %}
 
 {% settings_value 'BARCODE_ENABLE' as barcodes %}
-{% settings_value 'ENABLE_PLUGINS_NAVIGATION' as plugin_nav %}
+{% navigation_enabled as plugin_nav %}
 
 <nav class="navbar navbar-xs navbar-default navbar-fixed-top ">
   <div class="container-fluid">

From 5dd36c7587d9e2116e5041f08546d5a4869cfe7c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:50:21 +0200
Subject: [PATCH 187/493] testing safe url checks

---
 InvenTree/InvenTree/urls.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index d0b7d1cedf..8b987d70c5 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -127,7 +127,7 @@ translated_javascript_urls = [
 
 # Integration plugin urls
 interation_urls = []
-if InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
+if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
     for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):
             interation_urls.append(plugin.urlpatterns)

From dcab0c430cb8ecb5e96ff72423617bab3918d39e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:50:46 +0200
Subject: [PATCH 188/493] testing safe settings and app integration

---
 InvenTree/plugin/apps.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 996aa4c8a4..315d2d2d5d 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -15,7 +15,7 @@ class PluginConfig(AppConfig):
         plugins = settings.INTEGRATION_PLUGINS.items()
 
         # if plugin settings are enabled enhance the settings
-        if InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+        if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('settings'):
                     plugin_setting = plugin.settingspatterns
@@ -25,7 +25,7 @@ class PluginConfig(AppConfig):
                     InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
 
         # if plugin apps are enabled
-        if (not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
+        if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
             settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
             apps_changed = False
 

From d577d3778dc652f04dacf8b4bed13834d615abaa Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:53:11 +0200
Subject: [PATCH 189/493] PEP fix

---
 InvenTree/part/templatetags/plugin_extras.py | 1 +
 InvenTree/plugins/ShopifyIntegrationPlugin   | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
index 590b89d7ae..c5e86a6900 100644
--- a/InvenTree/part/templatetags/plugin_extras.py
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -28,6 +28,7 @@ def mixin_enabled(plugin, key, *args, **kwargs):
     """ Return if the mixin is existant and configured in the plugin """
     return plugin.mixin_enabled(key)
 
+
 @register.simple_tag()
 def navigation_enabled(*args, **kwargs):
     """Return if plugin navigation is enabled"""
diff --git a/InvenTree/plugins/ShopifyIntegrationPlugin b/InvenTree/plugins/ShopifyIntegrationPlugin
index 65c58c7e2d..6a9d1e622e 160000
--- a/InvenTree/plugins/ShopifyIntegrationPlugin
+++ b/InvenTree/plugins/ShopifyIntegrationPlugin
@@ -1 +1 @@
-Subproject commit 65c58c7e2d133bf2098f91f6f202a6f6a2f09eb9
+Subproject commit 6a9d1e622e6e64208df58c6a75a4a1caf12b20bf

From 11672096e7099426e1d1792bd61af8436fef4630 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:57:42 +0200
Subject: [PATCH 190/493] always check if app already loaded

---
 InvenTree/plugin/apps.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 315d2d2d5d..9e925f3be2 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -33,8 +33,9 @@ class PluginConfig(AppConfig):
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('app'):
                     plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-                    settings.INSTALLED_APPS += [plugin_path]
-                    apps_changed = True
+                    if plugin_path not in  settings.INSTALLED_APPS:
+                        settings.INSTALLED_APPS += [plugin_path]
+                        apps_changed = True
 
             # if apps were changed reload
             # TODO this is a bit jankey to be honest

From c2535cbcd7deda29b527ed2cbe0caa5f960a74b7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 19:59:02 +0200
Subject: [PATCH 191/493] PEP fix

---
 InvenTree/plugin/apps.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 9e925f3be2..5a16e0735d 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -33,7 +33,7 @@ class PluginConfig(AppConfig):
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('app'):
                     plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-                    if plugin_path not in  settings.INSTALLED_APPS:
+                    if plugin_path not in settings.INSTALLED_APPS:
                         settings.INSTALLED_APPS += [plugin_path]
                         apps_changed = True
 

From 5eb26c84b958a59e74c3b7b4a6fc6c5571eb4229 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 20:38:52 +0200
Subject: [PATCH 192/493] remove unneeded migration

---
 .github/workflows/mysql.yaml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/.github/workflows/mysql.yaml b/.github/workflows/mysql.yaml
index 058b0fd9c7..7c8638d49f 100644
--- a/.github/workflows/mysql.yaml
+++ b/.github/workflows/mysql.yaml
@@ -56,7 +56,6 @@ jobs:
           invoke install
       - name: Run Tests
         run: |
-          invoke migrate
           invoke test
       - name: Data Import Export
         run: |

From 20bb2d438e96ef380d6478887e7b7628cf5fdcf5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 17 Oct 2021 23:19:27 +0200
Subject: [PATCH 193/493] make settings protectable from output

---
 InvenTree/common/models.py | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 3ddad8ff5d..e99940aa64 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -77,7 +77,9 @@ class BaseInvenTreeSetting(models.Model):
         for key, value in settings.items():
             validator = cls.get_setting_validator(key)
 
-            if cls.validator_is_bool(validator):
+            if cls.is_protected(key):
+                value = '***'
+            elif cls.validator_is_bool(validator):
                 value = InvenTree.helpers.str2bool(value)
             elif cls.validator_is_int(validator):
                 try:
@@ -476,6 +478,19 @@ class BaseInvenTreeSetting(models.Model):
 
         return value
 
+    @classmethod
+    def is_protected(cls, key):
+        """
+        Check if the setting value is protected
+        """
+
+        key = str(key).strip().upper()
+
+        if key in cls.GLOBAL_SETTINGS:
+            return cls.GLOBAL_SETTINGS[key].get('protected', False)
+        else:
+            return False
+
 
 def settings_group_options():
     """build up group tuple for settings based on gour choices"""

From b36a1d47e1651f85f53161ac9c53e0d423a93ba6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 18 Oct 2021 22:39:08 +0200
Subject: [PATCH 194/493] move webhook receiver logic

---
 InvenTree/common/api.py    | 93 ++++----------------------------------
 InvenTree/common/models.py | 74 ++++++++++++++++++++++++++++++
 InvenTree/common/tests.py  | 12 ++---
 3 files changed, 90 insertions(+), 89 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index f2479b4a2a..9624d85a9d 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -6,10 +6,6 @@ Provides a JSON API for common components.
 from __future__ import unicode_literals
 
 import json
-import hmac
-import hashlib
-import base64
-from secrets import compare_digest
 
 from django.utils.decorators import method_decorator
 from django.urls import path
@@ -17,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt
 
 from rest_framework.views import APIView
 from rest_framework.response import Response
-from rest_framework.exceptions import PermissionDenied, NotFound, NotAcceptable
+from rest_framework.exceptions import NotAcceptable, NotFound
 from django_q.tasks import async_task
 
 from .models import WebhookEndpoint, WebhookMessage
@@ -33,12 +29,6 @@ class CsrfExemptMixin(object):
         return super(CsrfExemptMixin, self).dispatch(*args, **kwargs)
 
 
-class VerificationMethod:
-    NONE = 0
-    TOKEN = 1
-    HMAC = 2
-
-
 class WebhookView(CsrfExemptMixin, APIView):
     """
     Endpoint for receiving webhooks.
@@ -48,17 +38,9 @@ class WebhookView(CsrfExemptMixin, APIView):
     model_class = WebhookEndpoint
     run_async = False
 
-    # Token
-    TOKEN_NAME = "Token"
-    VERIFICATION_METHOD = VerificationMethod.NONE
-
-    MESSAGE_OK = "Message was received."
-    MESSAGE_TOKEN_ERROR = "Incorrect token in header."
-
     def post(self, request, endpoint, *args, **kwargs):
-        self.init(request, *args, **kwargs)
         # get webhook definition
-        self.get_webhook(endpoint, *args, **kwargs)
+        self._get_webhook(endpoint, request, *args, **kwargs)
 
         # check headers
         headers = request.headers
@@ -68,89 +50,34 @@ class WebhookView(CsrfExemptMixin, APIView):
             raise NotAcceptable(error.msg)
 
         # validate
-        self.validate_token(payload, headers, request)
+        self.webhook.validate_token(payload, headers, request)
         # process data
-        message = self.save_data(payload, headers, request)
+        message = self.webhook.save_data(payload, headers, request)
         if self.run_async:
             async_task(self._process_payload, message.id)
         else:
-            message.worked_on = self.process_payload(message, payload, headers)
+            message.worked_on = self.webhook.process_payload(message, payload, headers)
             message.save()
 
         # return results
-        return_kwargs = self.get_result(payload, headers, request)
+        return_kwargs = self.webhook.get_result(payload, headers, request)
         return Response(**return_kwargs)
 
     def _process_payload(self, message_id):
         message = WebhookMessage.objects.get(message_id=message_id)
-        process_result = self.process_payload(message, message.body, message.header)
+        process_result = self.webhook.process_payload(message, message.body, message.header)
         message.worked_on = process_result
         message.save()
 
-    # To be overridden
-    def init(self, request, *args, **kwargs):
-        self.token = ''
-        self.secret = ''
-        self.verify = self.VERIFICATION_METHOD
-
-    def get_webhook(self, endpoint):
+    def _get_webhook(self, endpoint, request, *args, **kwargs):
         try:
             webhook = self.model_class.objects.get(endpoint_id=endpoint)
             self.webhook = webhook
-            return self.process_webhook()
+            self.webhook.init(request, *args, **kwargs)
+            return self.webhook.process_webhook()
         except self.model_class.DoesNotExist:
             raise NotFound()
 
-    def process_webhook(self):
-        if self.webhook.token:
-            self.token = self.webhook.token
-            self.verify = VerificationMethod.TOKEN
-            # TODO make a object-setting
-        if self.webhook.secret:
-            self.secret = self.webhook.secret
-            self.verify = VerificationMethod.HMAC
-            # TODO make a object-setting
-        return True
-
-    def validate_token(self, payload, headers, request):
-        token = headers.get(self.TOKEN_NAME, "")
-
-        # no token
-        if self.verify == VerificationMethod.NONE:
-            pass
-
-        # static token
-        elif self.verify == VerificationMethod.TOKEN:
-            if not compare_digest(token, self.token):
-                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
-
-        # hmac token
-        elif self.verify == VerificationMethod.HMAC:
-            digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest()
-            computed_hmac = base64.b64encode(digest)
-            if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
-                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
-
-        return True
-
-    def save_data(self, payload, headers=None, request=None):
-        return WebhookMessage.objects.create(
-            # host=request.host,
-            # TODO fix
-            header=headers,
-            body=payload,
-            endpoint=self.webhook,
-        )
-
-    def process_payload(self, message, payload=None, headers=None):
-        return True
-
-    def get_result(self, payload, headers=None, request=None):
-        context = {}
-        context['data'] = {'message': self.MESSAGE_OK}
-        context['status'] = 200
-        return context
-
 
 common_api_urls = [
     path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index e99940aa64..023e4e7c18 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -10,6 +10,10 @@ import os
 import decimal
 import math
 import uuid
+import hmac
+import hashlib
+import base64
+from secrets import compare_digest
 
 from django.db import models, transaction
 from django.contrib.auth.models import User, Group
@@ -20,6 +24,8 @@ from djmoney.settings import CURRENCY_CHOICES
 from djmoney.contrib.exchange.models import convert_money
 from djmoney.contrib.exchange.exceptions import MissingRate
 
+from rest_framework.exceptions import PermissionDenied
+
 from django.utils.translation import ugettext_lazy as _
 from django.core.validators import MinValueValidator, URLValidator
 from django.core.exceptions import ValidationError
@@ -1248,6 +1254,12 @@ class ColorTheme(models.Model):
         return False
 
 
+class VerificationMethod:
+    NONE = 0
+    TOKEN = 1
+    HMAC = 2
+
+
 class WebhookEndpoint(models.Model):
     """ Defines a Webhook entdpoint
 
@@ -1260,6 +1272,13 @@ class WebhookEndpoint(models.Model):
         secret: Shared secret for HMAC verification,
     """
 
+    # Token
+    TOKEN_NAME = "Token"
+    VERIFICATION_METHOD = VerificationMethod.NONE
+
+    MESSAGE_OK = "Message was received."
+    MESSAGE_TOKEN_ERROR = "Incorrect token in header."
+
     endpoint_id = models.CharField(
         max_length=255,
         verbose_name=_('Endpoint'),
@@ -1304,6 +1323,61 @@ class WebhookEndpoint(models.Model):
         help_text=_('Shared secret for HMAC'),
     )
 
+    # To be overridden
+
+    def init(self, request, *args, **kwargs):
+        self.verify = self.VERIFICATION_METHOD
+
+    def process_webhook(self):
+        if self.token:
+            self.token = self.token
+            self.verify = VerificationMethod.TOKEN
+            # TODO make a object-setting
+        if self.secret:
+            self.secret = self.secret
+            self.verify = VerificationMethod.HMAC
+            # TODO make a object-setting
+        return True
+
+    def validate_token(self, payload, headers, request):
+        token = headers.get(self.TOKEN_NAME, "")
+
+        # no token
+        if self.verify == VerificationMethod.NONE:
+            pass
+
+        # static token
+        elif self.verify == VerificationMethod.TOKEN:
+            if not compare_digest(token, self.token):
+                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
+
+        # hmac token
+        elif self.verify == VerificationMethod.HMAC:
+            digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest()
+            computed_hmac = base64.b64encode(digest)
+            if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
+                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
+
+        return True
+
+    def save_data(self, payload, headers=None, request=None):
+        return WebhookMessage.objects.create(
+            # host=request.host,
+            # TODO fix
+            header=headers,
+            body=payload,
+            endpoint=self,
+        )
+
+    def process_payload(self, message, payload=None, headers=None):
+        return True
+
+    def get_result(self, payload, headers=None, request=None):
+        context = {}
+        context['data'] = {'message': self.MESSAGE_OK}
+        context['status'] = 200
+        return context
+
 
 class WebhookMessage(models.Model):
     """ Defines a webhook message
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 8121b46a6a..8a28d95bed 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -109,7 +109,7 @@ class WebhookMessageTests(TestCase):
 
         assert response.status_code == HTTPStatus.FORBIDDEN
         assert (
-            json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR
+            json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR
         )
 
     def test_bad_token(self):
@@ -120,7 +120,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.FORBIDDEN
-        assert (json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR)
+        assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
 
     def test_bad_url(self):
         response = self.client.post(
@@ -155,7 +155,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert json.loads(response.content)['message'] == WebhookView.MESSAGE_OK
+        assert json.loads(response.content)['message'] == WebhookView.model_class.MESSAGE_OK
 
     def test_bad_hmac(self):
         # delete token
@@ -170,7 +170,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.FORBIDDEN
-        assert (json.loads(response.content)['detail'] == WebhookView.MESSAGE_TOKEN_ERROR)
+        assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
 
     def test_success_hmac(self):
         # delete token
@@ -186,7 +186,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert json.loads(response.content)['message'] == WebhookView.MESSAGE_OK
+        assert json.loads(response.content)['message'] == WebhookView.model_class.MESSAGE_OK
 
     def test_success(self):
         response = self.client.post(
@@ -197,6 +197,6 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert json.loads(response.content)['message'] == WebhookView.MESSAGE_OK
+        assert json.loads(response.content)['message'] == WebhookView.model_class.MESSAGE_OK
         message = WebhookMessage.objects.get()
         assert message.body == {"this": "is a message"}

From 6147b079d17b26251e23e10984a6d33d6d2b7478 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 18 Oct 2021 23:03:00 +0200
Subject: [PATCH 195/493] safe url loading

---
 InvenTree/InvenTree/urls.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 8b987d70c5..e181d130fa 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -127,7 +127,12 @@ translated_javascript_urls = [
 
 # Integration plugin urls
 interation_urls = []
-if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
+plugin_url_enabled = False
+try:
+    plugin_url_enabled = InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL')
+except Exception as _e:
+    print(_e)
+if settings.TESTING or plugin_url_enabled:
     for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):
             interation_urls.append(plugin.urlpatterns)

From 515e1faad42be2096dd1857ccfaa8ee007dac77a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 00:31:25 +0200
Subject: [PATCH 196/493] return json rsponse on webhooks

---
 InvenTree/common/api.py    | 6 +++---
 InvenTree/common/models.py | 6 ++----
 2 files changed, 5 insertions(+), 7 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index 9624d85a9d..aa04715de6 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -7,12 +7,12 @@ from __future__ import unicode_literals
 
 import json
 
+from django.http.response import JsonResponse
 from django.utils.decorators import method_decorator
 from django.urls import path
 from django.views.decorators.csrf import csrf_exempt
 
 from rest_framework.views import APIView
-from rest_framework.response import Response
 from rest_framework.exceptions import NotAcceptable, NotFound
 from django_q.tasks import async_task
 
@@ -60,8 +60,8 @@ class WebhookView(CsrfExemptMixin, APIView):
             message.save()
 
         # return results
-        return_kwargs = self.webhook.get_result(payload, headers, request)
-        return Response(**return_kwargs)
+        data = self.webhook.get_result(payload, headers, request)
+        return JsonResponse(data)
 
     def _process_payload(self, message_id):
         message = WebhookMessage.objects.get(message_id=message_id)
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 023e4e7c18..630a8d03da 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1373,10 +1373,8 @@ class WebhookEndpoint(models.Model):
         return True
 
     def get_result(self, payload, headers=None, request=None):
-        context = {}
-        context['data'] = {'message': self.MESSAGE_OK}
-        context['status'] = 200
-        return context
+        data = {'message': self.MESSAGE_OK}
+        return data
 
 
 class WebhookMessage(models.Model):

From bf679f185f7c3c6028d24850d66a79f731a5865e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 00:32:26 +0200
Subject: [PATCH 197/493] always escalete object

---
 InvenTree/common/api.py | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index aa04715de6..c0ba53490a 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -17,6 +17,7 @@ from rest_framework.exceptions import NotAcceptable, NotFound
 from django_q.tasks import async_task
 
 from .models import WebhookEndpoint, WebhookMessage
+from InvenTree.helpers import inheritors
 
 
 class CsrfExemptMixin(object):
@@ -69,10 +70,18 @@ class WebhookView(CsrfExemptMixin, APIView):
         message.worked_on = process_result
         message.save()
 
+    def _escalate_object(self, obj):
+        classes = inheritors(obj.__class__)
+        for cls in classes:
+            mdl_name = cls._meta.model_name
+            if hasattr(obj, mdl_name):
+                return getattr(obj, mdl_name)
+        return obj
+
     def _get_webhook(self, endpoint, request, *args, **kwargs):
         try:
             webhook = self.model_class.objects.get(endpoint_id=endpoint)
-            self.webhook = webhook
+            self.webhook = self._escalate_object(webhook)
             self.webhook.init(request, *args, **kwargs)
             return self.webhook.process_webhook()
         except self.model_class.DoesNotExist:

From 69ee4ea14fa6b100ae77212deacc873b5475b6cc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 00:32:51 +0200
Subject: [PATCH 198/493] and here is the helper

---
 InvenTree/InvenTree/helpers.py | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 319b88cb09..0d8a84a5d7 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -667,3 +667,18 @@ def clean_decimal(number):
         return Decimal(0)
 
     return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
+
+
+def inheritors(cls):
+    """
+    Return all classes that are subclasses from the supplied cls
+    """
+    subcls = set()
+    work = [cls]
+    while work:
+        parent = work.pop()
+        for child in parent.__subclasses__():
+            if child not in subcls:
+                subcls.add(child)
+                work.append(child)
+    return subcls

From e37477eb156f243a0ca1c321c80c82f59735fef8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 19:54:20 +0200
Subject: [PATCH 199/493] remove url load setting for plugin

---
 InvenTree/InvenTree/urls.py                        | 12 +++---------
 InvenTree/common/models.py                         |  6 ------
 InvenTree/templates/InvenTree/settings/plugin.html |  1 -
 3 files changed, 3 insertions(+), 16 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index e181d130fa..e70dbf8434 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -127,15 +127,9 @@ translated_javascript_urls = [
 
 # Integration plugin urls
 interation_urls = []
-plugin_url_enabled = False
-try:
-    plugin_url_enabled = InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL')
-except Exception as _e:
-    print(_e)
-if settings.TESTING or plugin_url_enabled:
-    for plugin in settings.INTEGRATION_PLUGINS.values():
-        if plugin.mixin_enabled('urls'):
-            interation_urls.append(plugin.urlpatterns)
+for plugin in settings.INTEGRATION_PLUGINS.values():
+    if plugin.mixin_enabled('urls'):
+        interation_urls.append(plugin.urlpatterns)
 
 urlpatterns = [
     url(r'^part/', include(part_urls)),
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 630a8d03da..3e2f787457 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -875,12 +875,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'default': '',
             'choices': settings_group_options
         },
-        'ENABLE_PLUGINS_URL': {
-            'name': _('Enable URL integration'),
-            'description': _('Enable plugins to add URL routes'),
-            'default': False,
-            'validator': bool,
-        },
         'ENABLE_PLUGINS_NAVIGATION': {
             'name': _('Enable navigation integration'),
             'description': _('Enable plugins to integrate into navigation'),
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index acacbdff4d..a7f63a65ef 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -19,7 +19,6 @@
 <table class='table table-striped table-condensed'>
     {% include "InvenTree/settings/header.html" %}
     <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_SETTING"%}
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}

From 23558e235b542e482f7a3d1e7f975da821dfc981 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 20:58:35 +0200
Subject: [PATCH 200/493] PEP fix

---
 InvenTree/InvenTree/urls.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index e70dbf8434..d21f7b49c3 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -43,7 +43,6 @@ from .views import AppearanceSelectView, SettingCategorySelectView
 from .views import DynamicJsView
 
 from common.views import SettingEdit, UserSettingEdit
-from common.models import InvenTreeSetting
 
 from .api import InfoView, NotFoundView
 from .api import ActionPluginView

From f86bd4dd6b35fa4aa72e680fd662cf7fac57ddb9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 20:59:14 +0200
Subject: [PATCH 201/493] catch db not loaded

---
 InvenTree/plugin/apps.py | 57 ++++++++++++++++++++++------------------
 1 file changed, 31 insertions(+), 26 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 5a16e0735d..ee9bf3bef6 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -5,6 +5,7 @@ from typing import OrderedDict
 
 from django.apps import AppConfig, apps
 from django.conf import settings
+from django.db.utils import OperationalError, ProgrammingError
 
 
 class PluginConfig(AppConfig):
@@ -14,33 +15,37 @@ class PluginConfig(AppConfig):
         from common.models import InvenTreeSetting
         plugins = settings.INTEGRATION_PLUGINS.items()
 
-        # if plugin settings are enabled enhance the settings
-        if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
-            for slug, plugin in plugins:
-                if plugin.mixin_enabled('settings'):
-                    plugin_setting = plugin.settingspatterns
-                    settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
+        try:
+            # if plugin settings are enabled enhance the settings
+            if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+                for slug, plugin in plugins:
+                    if plugin.mixin_enabled('settings'):
+                        plugin_setting = plugin.settingspatterns
+                        settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
 
-                    # Add to settings dir
-                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
+                        # Add to settings dir
+                        InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
 
-        # if plugin apps are enabled
-        if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
-            settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
-            apps_changed = False
+            # if plugin apps are enabled
+            if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+                settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
+                apps_changed = False
 
-            # add them to the INSTALLED_APPS
-            for slug, plugin in plugins:
-                if plugin.mixin_enabled('app'):
-                    plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-                    if plugin_path not in settings.INSTALLED_APPS:
-                        settings.INSTALLED_APPS += [plugin_path]
-                        apps_changed = True
+                # add them to the INSTALLED_APPS
+                for slug, plugin in plugins:
+                    if plugin.mixin_enabled('app'):
+                        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+                        if plugin_path not in settings.INSTALLED_APPS:
+                            settings.INSTALLED_APPS += [plugin_path]
+                            apps_changed = True
 
-            # if apps were changed reload
-            # TODO this is a bit jankey to be honest
-            if apps_changed:
-                apps.app_configs = OrderedDict()
-                apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
-                apps.clear_cache()
-                apps.populate(settings.INSTALLED_APPS)
+                # if apps were changed reload
+                # TODO this is a bit jankey to be honest
+                if apps_changed:
+                    apps.app_configs = OrderedDict()
+                    apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+                    apps.clear_cache()
+                    apps.populate(settings.INSTALLED_APPS)
+        except (OperationalError, ProgrammingError):
+            # Exception if the database has not been migrated yet
+            pass

From 1c93a126ae20bfb6e77acf6fd471811702c611fe Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 21:39:58 +0200
Subject: [PATCH 202/493] Revert "remove url load setting for plugin"

This reverts commit e37477eb156f243a0ca1c321c80c82f59735fef8.
---
 InvenTree/InvenTree/urls.py                        | 13 ++++++++++---
 InvenTree/common/models.py                         |  6 ++++++
 InvenTree/templates/InvenTree/settings/plugin.html |  1 +
 3 files changed, 17 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index d21f7b49c3..e181d130fa 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -43,6 +43,7 @@ from .views import AppearanceSelectView, SettingCategorySelectView
 from .views import DynamicJsView
 
 from common.views import SettingEdit, UserSettingEdit
+from common.models import InvenTreeSetting
 
 from .api import InfoView, NotFoundView
 from .api import ActionPluginView
@@ -126,9 +127,15 @@ translated_javascript_urls = [
 
 # Integration plugin urls
 interation_urls = []
-for plugin in settings.INTEGRATION_PLUGINS.values():
-    if plugin.mixin_enabled('urls'):
-        interation_urls.append(plugin.urlpatterns)
+plugin_url_enabled = False
+try:
+    plugin_url_enabled = InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL')
+except Exception as _e:
+    print(_e)
+if settings.TESTING or plugin_url_enabled:
+    for plugin in settings.INTEGRATION_PLUGINS.values():
+        if plugin.mixin_enabled('urls'):
+            interation_urls.append(plugin.urlpatterns)
 
 urlpatterns = [
     url(r'^part/', include(part_urls)),
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 3e2f787457..630a8d03da 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -875,6 +875,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'default': '',
             'choices': settings_group_options
         },
+        'ENABLE_PLUGINS_URL': {
+            'name': _('Enable URL integration'),
+            'description': _('Enable plugins to add URL routes'),
+            'default': False,
+            'validator': bool,
+        },
         'ENABLE_PLUGINS_NAVIGATION': {
             'name': _('Enable navigation integration'),
             'description': _('Enable plugins to integrate into navigation'),
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index a7f63a65ef..acacbdff4d 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -19,6 +19,7 @@
 <table class='table table-striped table-condensed'>
     {% include "InvenTree/settings/header.html" %}
     <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_SETTING"%}
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}

From 24a8c3469963743223a1882d07bc64a1e00a260a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 19 Oct 2021 21:42:29 +0200
Subject: [PATCH 203/493] only check if plugin urls are enabled if db ready

---
 InvenTree/InvenTree/urls.py | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index e181d130fa..2bd04b009b 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -31,6 +31,7 @@ from report.api import report_api_urls
 
 from django.conf import settings
 from django.conf.urls.static import static
+from django.db.utils import OperationalError, ProgrammingError
 
 from django.views.generic.base import RedirectView
 from rest_framework.documentation import include_docs_urls
@@ -127,15 +128,14 @@ translated_javascript_urls = [
 
 # Integration plugin urls
 interation_urls = []
-plugin_url_enabled = False
 try:
-    plugin_url_enabled = InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL')
-except Exception as _e:
-    print(_e)
-if settings.TESTING or plugin_url_enabled:
-    for plugin in settings.INTEGRATION_PLUGINS.values():
-        if plugin.mixin_enabled('urls'):
-            interation_urls.append(plugin.urlpatterns)
+    if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
+        for plugin in settings.INTEGRATION_PLUGINS.values():
+            if plugin.mixin_enabled('urls'):
+                interation_urls.append(plugin.urlpatterns)
+except (OperationalError, ProgrammingError):
+    # Exception if the database has not been migrated yet
+    pass
 
 urlpatterns = [
     url(r'^part/', include(part_urls)),

From 592c5f0d6c7b4863d6d85ece3762a55d6a2a61e5 Mon Sep 17 00:00:00 2001
From: Matthias Mair <66015116+matmair@users.noreply.github.com>
Date: Tue, 19 Oct 2021 21:44:42 +0200
Subject: [PATCH 204/493] Delete .gitmodules

---
 .gitmodules | 3 ---
 1 file changed, 3 deletions(-)
 delete mode 100644 .gitmodules

diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 2eb40b3aec..0000000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "inventree/plugins/ShopifyIntegrationPlugin"]
-	path = inventree/plugins/ShopifyIntegrationPlugin
-	url = https://github.com/matmair/ShopifyIntegrationPlugin

From 1b72dfeae62f0e44cd27139355a19509aca95356 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 22:04:36 +0200
Subject: [PATCH 205/493] fix header safeing

---
 InvenTree/common/models.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 630a8d03da..4c710775a8 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -11,6 +11,7 @@ import decimal
 import math
 import uuid
 import hmac
+import json
 import hashlib
 import base64
 from secrets import compare_digest
@@ -1362,9 +1363,8 @@ class WebhookEndpoint(models.Model):
 
     def save_data(self, payload, headers=None, request=None):
         return WebhookMessage.objects.create(
-            # host=request.host,
-            # TODO fix
-            header=headers,
+            host=request.get_host(),
+            header=json.dumps({key: val for key, val in headers.items()}),
             body=payload,
             endpoint=self,
         )

From a03c5609147125937cd07db9cccbdebf8f874836 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 22:06:32 +0200
Subject: [PATCH 206/493] refactor result processing

---
 InvenTree/common/api.py | 20 +++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index c0ba53490a..eed7d4c0f8 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -57,8 +57,10 @@ class WebhookView(CsrfExemptMixin, APIView):
         if self.run_async:
             async_task(self._process_payload, message.id)
         else:
-            message.worked_on = self.webhook.process_payload(message, payload, headers)
-            message.save()
+            self.process_result(
+                self.webhook.process_payload(message, payload, headers),
+                message,
+            )
 
         # return results
         data = self.webhook.get_result(payload, headers, request)
@@ -66,9 +68,17 @@ class WebhookView(CsrfExemptMixin, APIView):
 
     def _process_payload(self, message_id):
         message = WebhookMessage.objects.get(message_id=message_id)
-        process_result = self.webhook.process_payload(message, message.body, message.header)
-        message.worked_on = process_result
-        message.save()
+        self.process_result(
+            self.webhook.process_payload(message, message.body, message.header),
+            message,
+        )
+
+    def process_result(self, result, message):
+        if result:
+            message.worked_on = result
+            message.save()
+        else:
+            message.delete()
 
     def _escalate_object(self, obj):
         classes = inheritors(obj.__class__)

From c6a5a4435502acbd536774e546035a9744315171 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 22:07:29 +0200
Subject: [PATCH 207/493] format results as HTTP result

---
 InvenTree/common/api.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index eed7d4c0f8..eddd67dfa4 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -7,7 +7,7 @@ from __future__ import unicode_literals
 
 import json
 
-from django.http.response import JsonResponse
+from django.http.response import HttpResponse
 from django.utils.decorators import method_decorator
 from django.urls import path
 from django.views.decorators.csrf import csrf_exempt
@@ -64,7 +64,7 @@ class WebhookView(CsrfExemptMixin, APIView):
 
         # return results
         data = self.webhook.get_result(payload, headers, request)
-        return JsonResponse(data)
+        return HttpResponse(data)
 
     def _process_payload(self, message_id):
         message = WebhookMessage.objects.get(message_id=message_id)

From 593c7a41de3517a5d10f62b513753c675e171fd8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 22:09:50 +0200
Subject: [PATCH 208/493] refactor fnc name

---
 InvenTree/common/api.py    | 2 +-
 InvenTree/common/models.py | 5 ++---
 2 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index eddd67dfa4..d568462a47 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -63,7 +63,7 @@ class WebhookView(CsrfExemptMixin, APIView):
             )
 
         # return results
-        data = self.webhook.get_result(payload, headers, request)
+        data = self.webhook.get_return(payload, headers, request)
         return HttpResponse(data)
 
     def _process_payload(self, message_id):
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 4c710775a8..422deb4f6e 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -1372,9 +1372,8 @@ class WebhookEndpoint(models.Model):
     def process_payload(self, message, payload=None, headers=None):
         return True
 
-    def get_result(self, payload, headers=None, request=None):
-        data = {'message': self.MESSAGE_OK}
-        return data
+    def get_return(self, payload, headers=None, request=None):
+        return self.MESSAGE_OK
 
 
 class WebhookMessage(models.Model):

From ae086ba6d40f85ff1b485fbef8cfef9cdc957b2c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 22:17:10 +0200
Subject: [PATCH 209/493]  rename

---
 InvenTree/common/api.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index d568462a47..c7281e4950 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -57,7 +57,7 @@ class WebhookView(CsrfExemptMixin, APIView):
         if self.run_async:
             async_task(self._process_payload, message.id)
         else:
-            self.process_result(
+            self._process_result(
                 self.webhook.process_payload(message, payload, headers),
                 message,
             )
@@ -68,12 +68,12 @@ class WebhookView(CsrfExemptMixin, APIView):
 
     def _process_payload(self, message_id):
         message = WebhookMessage.objects.get(message_id=message_id)
-        self.process_result(
+        self._process_result(
             self.webhook.process_payload(message, message.body, message.header),
             message,
         )
 
-    def process_result(self, result, message):
+    def _process_result(self, result, message):
         if result:
             message.worked_on = result
             message.save()

From d29e548c05abd4c8570ee2214eb519460ed67d05 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 22:38:55 +0200
Subject: [PATCH 210/493] fix test

---
 InvenTree/common/tests.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 8a28d95bed..a5d3014f69 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -155,7 +155,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert json.loads(response.content)['message'] == WebhookView.model_class.MESSAGE_OK
+        assert response.content == WebhookView.model_class.MESSAGE_OK
 
     def test_bad_hmac(self):
         # delete token

From 48edaa9e2a441894d6063f6fabe49e7814c12323 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 20 Oct 2021 23:05:45 +0200
Subject: [PATCH 211/493] not all test be fixed

---
 InvenTree/common/tests.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index a5d3014f69..2fbb6b902f 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -155,7 +155,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert response.content == WebhookView.model_class.MESSAGE_OK
+        assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
 
     def test_bad_hmac(self):
         # delete token
@@ -186,7 +186,7 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert json.loads(response.content)['message'] == WebhookView.model_class.MESSAGE_OK
+        assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
 
     def test_success(self):
         response = self.client.post(
@@ -197,6 +197,6 @@ class WebhookMessageTests(TestCase):
         )
 
         assert response.status_code == HTTPStatus.OK
-        assert json.loads(response.content)['message'] == WebhookView.model_class.MESSAGE_OK
+        assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
         message = WebhookMessage.objects.get()
         assert message.body == {"this": "is a message"}

From b31a1aa4cce0567061cd6072fa56ec376b5b6d33 Mon Sep 17 00:00:00 2001
From: Matthias Mair <matmair@live.de>
Date: Sat, 30 Oct 2021 18:10:56 +0000
Subject: [PATCH 212/493] fix plugin rendering in settings nav

---
 InvenTree/templates/InvenTree/settings/sidebar.html | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html
index 1dedeeee63..c54b80b164 100644
--- a/InvenTree/templates/InvenTree/settings/sidebar.html
+++ b/InvenTree/templates/InvenTree/settings/sidebar.html
@@ -1,6 +1,7 @@
 {% load i18n %}
 {% load static %}
 {% load inventree_extras %}
+{% load plugin_extras %}
 
 {% include "sidebar_header.html" with text="User Settings" icon='fa-user' %}
 
@@ -34,7 +35,7 @@
 {% plugin_list as pl_list %}
 {% for plugin_key, plugin in pl_list.items %}
     {% if plugin.registered_mixins %}
-        {% include "sidebar_item.html" with label='plugin-'|add:{{plugin_key}} text="plugin.human_name %}
+        {% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %}
     {% endif %}
 {% endfor %}
 

From 13cc329dc6ef51e42d6cdb70af6b0cc0fa86e80e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 30 Oct 2021 22:14:57 +0200
Subject: [PATCH 213/493] remove unneeded headers

---
 InvenTree/templates/InvenTree/settings/mixins/settings.html | 1 -
 InvenTree/templates/InvenTree/settings/plugin.html          | 1 -
 2 files changed, 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html
index 9cd34639e8..3b5b1e80e3 100644
--- a/InvenTree/templates/InvenTree/settings/mixins/settings.html
+++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html
@@ -5,7 +5,6 @@
 {% plugin_settings plugin_key as plugin_settings %}
 
 <table class='table table-striped table-condensed'>
-    {% include "InvenTree/settings/header.html" %}
     <tbody>
     {% for setting in plugin_settings %}
         {% include "InvenTree/settings/setting.html" with key=setting%}
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index acacbdff4d..b13e075fab 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -17,7 +17,6 @@
 </div>
 
 <table class='table table-striped table-condensed'>
-    {% include "InvenTree/settings/header.html" %}
     <tbody>
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}

From 0ae514e7be8fef1d726c8a1e8f61526ac5d26f6f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 30 Oct 2021 22:15:21 +0200
Subject: [PATCH 214/493] hide breadcrumb section

---
 InvenTree/templates/InvenTree/settings/settings.html | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index 3a39a82c9d..fa8cd755c7 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -13,6 +13,9 @@
 {% include "InvenTree/settings/sidebar.html" %}
 {% endblock %}
 
+{% block breadcrumb_list %}
+{% endblock %}
+
 {% block content %}
 
 {% include "InvenTree/settings/user.html" %}

From 199254dfe9094b9b507786adcadcd25162d93f5b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 30 Oct 2021 22:35:47 +0200
Subject: [PATCH 215/493] adjust navigation integration to new style

---
 InvenTree/templates/navbar.html | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index feec40a952..38fcead610 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -64,12 +64,13 @@
         {% for plugin_key, plugin in pl_list.items %}
           {% mixin_enabled plugin 'navigation' as navigation %}
           {% if navigation %}
-
-          <li class='nav navbar-nav'>
-            <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='{{plugin.navigation_icon}} icon-header'></span>{{plugin.navigation_name}}</a>
+          <li class='nav-item dropdown'>
+            <a class='nav-link dropdown-toggle' data-bs-toggle="dropdown" aria-expanded="false" href='#'>
+              <span class='{{plugin.navigation_icon}} icon-header'></span>{{plugin.navigation_name}}
+            </a>
             <ul class='dropdown-menu'>
              {% for nav_item in plugin.navigation %}
-                <li><a href="{% url nav_item.link %}"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
+                <li><a href="{% url nav_item.link %}" class="dropdown-item"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
              {% endfor %}
             </ul>
           </li>

From 93a28bbabac99baa3ff1268c84befd37d4f25d04 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 03:43:39 +0100
Subject: [PATCH 216/493] enable setup hooks Fixes #2218

---
 InvenTree/InvenTree/settings.py | 6 ++++++
 InvenTree/plugin/apps.py        | 7 ++++++-
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index d46704e85b..38d7ae8d93 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -19,6 +19,7 @@ import string
 import shutil
 import sys
 import importlib
+from importlib import metadata
 from datetime import datetime
 
 import moneyed
@@ -821,6 +822,11 @@ for plugin in PLUGIN_DIRS:
     if modules:
         [PLUGINS.append(item) for item in modules]
 
+# Get plugins from setup entry points
+for entry in metadata.entry_points().get('inventree_plugins', []):
+    plugin = entry.load()
+    PLUGINS.append(plugin)
+
 # collect integration plugins
 INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGIN_SETTING = {}
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index ee9bf3bef6..c6968ff90b 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -34,7 +34,12 @@ class PluginConfig(AppConfig):
                 # add them to the INSTALLED_APPS
                 for slug, plugin in plugins:
                     if plugin.mixin_enabled('app'):
-                        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+                        try:
+                            # for local path plugins
+                            plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+                        except ValueError:
+                            # plugin is shipped as package
+                            plugin_path = plugin.PLUGIN_NAME
                         if plugin_path not in settings.INSTALLED_APPS:
                             settings.INSTALLED_APPS += [plugin_path]
                             apps_changed = True

From 7fbf25840f256fcbc95c520f328217a93434cef5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 03:44:30 +0100
Subject: [PATCH 217/493] fix problem with iso format dates

---
 InvenTree/plugin/integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index d3e521c21b..dc92f35860 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -316,7 +316,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         if not name:
             name = self.commit.get('date')
         else:
-            name = datetime.fromisoformat(name)
+            name = datetime.fromisoformat(str(name))
         if not name:
             name = _('No date found')
         return name

From cf0c8ec2ea794ddaf2bfd1018a80d7abb2386d0c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 03:44:48 +0100
Subject: [PATCH 218/493] remove builtin integrations

---
 InvenTree/plugin/builtin/integration/__init__.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 InvenTree/plugin/builtin/integration/__init__.py

diff --git a/InvenTree/plugin/builtin/integration/__init__.py b/InvenTree/plugin/builtin/integration/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000

From eea6c8675c57947cf870ace1709423ca6ce488ec Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 10:12:30 +0100
Subject: [PATCH 219/493] PEP fix

---
 InvenTree/common/admin.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py
index 19361af325..6aa31bcd0d 100644
--- a/InvenTree/common/admin.py
+++ b/InvenTree/common/admin.py
@@ -27,6 +27,8 @@ admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
 admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
 admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
 admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
+
+
 class NotificationEntryAdmin(admin.ModelAdmin):
 
     list_display = ('key', 'uid', 'updated', )

From a88f1442399f95d2fef4b4dea3b4c09b7c5df2a5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 10:17:39 +0100
Subject: [PATCH 220/493] merge fixes

---
 InvenTree/common/admin.py | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py
index 6aa31bcd0d..cd521f8b38 100644
--- a/InvenTree/common/admin.py
+++ b/InvenTree/common/admin.py
@@ -23,12 +23,6 @@ class WebhookAdmin(ImportExportModelAdmin):
     list_display = ('endpoint_id', 'name', 'active', 'user')
 
 
-admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
-admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
-admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
-admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
-
-
 class NotificationEntryAdmin(admin.ModelAdmin):
 
     list_display = ('key', 'uid', 'updated', )
@@ -36,4 +30,6 @@ class NotificationEntryAdmin(admin.ModelAdmin):
 
 admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
 admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
+admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
+admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
 admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)

From b54f9c9c13b05091851f41f276e6472e1b2bc514 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 10:24:05 +0100
Subject: [PATCH 221/493] provide backport for 3.7

---
 InvenTree/InvenTree/settings.py | 7 ++++++-
 requirements.txt                | 1 +
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 38d7ae8d93..a20fb793cd 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -19,9 +19,14 @@ import string
 import shutil
 import sys
 import importlib
-from importlib import metadata
 from datetime import datetime
 
+try:
+    from importlib import metadata
+except:
+    import importlib_metadata as metadata
+
+
 import moneyed
 
 import yaml
diff --git a/requirements.txt b/requirements.txt
index b9f1dfd692..155cd49e14 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -27,6 +27,7 @@ django-weasyprint==1.0.1        # django weasyprint integration
 djangorestframework==3.12.4     # DRF framework
 flake8==3.8.3                   # PEP checking
 gunicorn>=20.1.0                # Gunicorn web server
+importlib_metadata              # Backport for importlib.metadata
 inventree                       # Install the latest version of the InvenTree API python library
 pep8-naming==0.11.1             # PEP naming convention extension
 pillow==8.3.2                   # Image manipulation

From e12e93f19e0fda6c922cb9d8889215b58245d2d8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 10:27:40 +0100
Subject: [PATCH 222/493] merge migrations

---
 ...12_notificationentry_0014_auto_20210912_1804.py | 14 ++++++++++++++
 1 file changed, 14 insertions(+)
 create mode 100644 InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py

diff --git a/InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py b/InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py
new file mode 100644
index 0000000000..8d2d4d0b18
--- /dev/null
+++ b/InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.2.5 on 2021-11-04 09:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0012_notificationentry'),
+        ('common', '0014_auto_20210912_1804'),
+    ]
+
+    operations = [
+    ]

From 50e5bfc4a48f7cc4ff7ea998eb835e58c5203d74 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 12:42:22 +0100
Subject: [PATCH 223/493] flag if plugin was packaged

---
 InvenTree/InvenTree/settings.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index a20fb793cd..182e45f8de 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -830,6 +830,7 @@ for plugin in PLUGIN_DIRS:
 # Get plugins from setup entry points
 for entry in metadata.entry_points().get('inventree_plugins', []):
     plugin = entry.load()
+    plugin.is_package = True
     PLUGINS.append(plugin)
 
 # collect integration plugins
@@ -838,5 +839,12 @@ INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
 
 for plugin in inventree_plugins.load_integration_plugins():
+    # check if package
+    was_packaged = getattr(plugin, 'is_package', False)
+
+    # init package
+    plugin.is_package = was_packaged
     plugin = plugin()
+    plugin.is_package = was_packaged
+    # safe reference
     INTEGRATION_PLUGINS[plugin.slug] = plugin

From e654ba786f621087f9fc464c7644a1f6e7bbdfc3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 12:43:27 +0100
Subject: [PATCH 224/493] decide where transit info is loaded from based on
 install method

---
 InvenTree/plugin/integration.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index dc92f35860..7ed1d08152 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -352,8 +352,13 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
     def set_sign_values(self):
         """add the last commit of the plugins class file into plugins context"""
-        # fetch git log
-        commit = self.get_plugin_commit()
+        if self.is_package:
+            # is a package - no signing - no commit
+            commit = {}
+        else:
+            # fetch git log
+            commit = self.get_plugin_commit()
+
         # resolve state
         sign_state = getattr(GitStatus, commit['verified'], GitStatus.N)
 

From cb30188623bd1c8bbf7d8e8b3bb9bb02aa13659b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 12:49:19 +0100
Subject: [PATCH 225/493] fix commit references

---
 InvenTree/plugin/integration.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 7ed1d08152..fe28e3df5d 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -360,15 +360,15 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             commit = self.get_plugin_commit()
 
         # resolve state
-        sign_state = getattr(GitStatus, commit['verified'], GitStatus.N)
+        sign_state = getattr(GitStatus, str(commit.get('verified')), GitStatus.N)
 
         # set variables
         self.commit = commit
         self.sign_state = sign_state
 
         # process date
-        if self.commit['date']:
-            self.commit['date'] = datetime.fromisoformat(self.commit['date'])
+        if self.commit.get('date'):
+            self.commit['date'] = datetime.fromisoformat(self.commit.get('date'))
 
         if sign_state.status == 0:
             self.sign_color = 'success'

From c085a868913cfea65f87550ecd19bd74abc1335d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 12:55:39 +0100
Subject: [PATCH 226/493] naming refactor

---
 InvenTree/plugin/integration.py               | 20 +++++++++----------
 .../InvenTree/settings/plugin_settings.html   | 12 +++++------
 2 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index fe28e3df5d..bf4dae4821 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -280,7 +280,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         self.def_path = inspect.getfile(self.__class__)
         self.path = os.path.dirname(self.def_path)
 
-        self.set_sign_values()
+        self.set_package()
 
     # properties
     @property
@@ -304,7 +304,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         """returns author of plugin - either from plugin settings or git"""
         name = getattr(self, 'AUTHOR', None)
         if not name:
-            name = self.commit.get('author')
+            name = self.package.get('author')
         if not name:
             name = _('No author found')
         return name
@@ -314,7 +314,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         """returns publishing date of plugin - either from plugin settings or git"""
         name = getattr(self, 'PUBLISH_DATE', None)
         if not name:
-            name = self.commit.get('date')
+            name = self.package.get('date')
         else:
             name = datetime.fromisoformat(str(name))
         if not name:
@@ -350,25 +350,25 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         """get last git commit for plugin"""
         return get_git_log(self.def_path)
 
-    def set_sign_values(self):
+    def set_package(self):
         """add the last commit of the plugins class file into plugins context"""
         if self.is_package:
             # is a package - no signing - no commit
-            commit = {}
+            package = {}
         else:
             # fetch git log
-            commit = self.get_plugin_commit()
+            package = self.get_plugin_commit()
 
         # resolve state
-        sign_state = getattr(GitStatus, str(commit.get('verified')), GitStatus.N)
+        sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
 
         # set variables
-        self.commit = commit
+        self.package = package
         self.sign_state = sign_state
 
         # process date
-        if self.commit.get('date'):
-            self.commit['date'] = datetime.fromisoformat(self.commit.get('date'))
+        if self.package.get('date'):
+            self.package['date'] = datetime.fromisoformat(self.package.get('date'))
 
         if sign_state.status == 0:
             self.sign_color = 'success'
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 5e516b0d63..132415a5a6 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -56,29 +56,29 @@
             <col width='25'>
             <tr>
                 <td><span class='fas fa-user'></span></td>
-                <td>{% trans "Commit Author" %}</td><td>{{ plugin.commit.author }} - {{ plugin.commit.mail }}{% include "clip.html" %}</td>
+                <td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='fas fa-calendar-alt'></span></td>
-                <td>{% trans "Commit Date" %}</td><td>{{ plugin.commit.date }}{% include "clip.html" %}</td>
+                <td>{% trans "Commit Date" %}</td><td>{{ plugin.package.date }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='fas fa-code-branch'></span></td>
-                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.commit.hash }}{% include "clip.html" %}</td>
+                <td>{% trans "Commit Hash" %}</td><td>{{ plugin.package.hash }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='fas fa-envelope'></span></td>
-                <td>{% trans "Commit Message" %}</td><td>{{ plugin.commit.message }}{% include "clip.html" %}</td>
+                <td>{% trans "Commit Message" %}</td><td>{{ plugin.package.message }}{% include "clip.html" %}</td>
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
                 <td>{% trans "Commit Sign Status" %}</td>
-                <td class="bg-{{plugin.sign_color}}">{% if plugin.commit.verified %}{{ plugin.commit.verified }}: {% endif%}{{ plugin.sign_state.msg }}</td>
+                <td class="bg-{{plugin.sign_color}}">{% if plugin.package.verified %}{{ plugin.package.verified }}: {% endif%}{{ plugin.sign_state.msg }}</td>
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td>
                 <td>{% trans "Commit Sign Key" %}</td>
-                <td class="bg-{{plugin.sign_color}}">{{ plugin.commit.key }}{% include "clip.html" %}</td>
+                <td class="bg-{{plugin.sign_color}}">{{ plugin.package.key }}{% include "clip.html" %}</td>
             </tr>
         </table>
     </div>

From dd617144350b8dd58068509ade467cc92955daf7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:04:02 +0100
Subject: [PATCH 227/493] refactor

---
 InvenTree/plugin/integration.py | 27 +++++++++++++--------------
 1 file changed, 13 insertions(+), 14 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index bf4dae4821..27996c7ff7 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -346,33 +346,32 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         return False
 
     # git
-    def get_plugin_commit(self):
+    def get_package_commit(self):
         """get last git commit for plugin"""
         return get_git_log(self.def_path)
 
     def set_package(self):
-        """add the last commit of the plugins class file into plugins context"""
+        """add packaging info of the plugins into plugins context"""
         if self.is_package:
-            # is a package - no signing - no commit
+            # is a package - no commit
             package = {}
         else:
-            # fetch git log
-            package = self.get_plugin_commit()
-
-        # resolve state
-        sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
-
-        # set variables
-        self.package = package
-        self.sign_state = sign_state
+            # fetch git commit
+            package = self.get_package_commit()
 
         # process date
-        if self.package.get('date'):
-            self.package['date'] = datetime.fromisoformat(self.package.get('date'))
+        if package.get('date'):
+            package['date'] = datetime.fromisoformat(package.get('date'))
 
+        # process sign state
+        sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N)
         if sign_state.status == 0:
             self.sign_color = 'success'
         elif sign_state.status == 1:
             self.sign_color = 'warning'
         else:
             self.sign_color = 'danger'
+
+        # set variables
+        self.package = package
+        self.sign_state = sign_state

From 285e6fe93eeb4bac98ff3c18eb316e0455e8a1a7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:07:35 +0100
Subject: [PATCH 228/493] prepare fnc for loading metadata

---
 InvenTree/plugin/integration.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 27996c7ff7..178fa1309a 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -345,16 +345,20 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             return getattr(self, fnc_name, True)
         return False
 
-    # git
+    # package info
     def get_package_commit(self):
         """get last git commit for plugin"""
         return get_git_log(self.def_path)
 
+    def get_package_metadata(self):
+        """get package metadata for plugin"""
+        return {}
+
     def set_package(self):
         """add packaging info of the plugins into plugins context"""
         if self.is_package:
             # is a package - no commit
-            package = {}
+            package = self.get_package_metadata()
         else:
             # fetch git commit
             package = self.get_package_commit()

From 2fc8efbfb21d21c9d57e9fd275e927148d8e2e09 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:08:30 +0100
Subject: [PATCH 229/493] simplify syntax

---
 InvenTree/plugin/integration.py | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 178fa1309a..10b3a3a0a6 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -356,12 +356,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
     def set_package(self):
         """add packaging info of the plugins into plugins context"""
-        if self.is_package:
-            # is a package - no commit
-            package = self.get_package_metadata()
-        else:
-            # fetch git commit
-            package = self.get_package_commit()
+        package = self.get_package_metadata() if self.is_package else self.get_package_commit()
 
         # process date
         if package.get('date'):

From 41954fd2d6110d16c47df437c37e3bf08c55969f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:19:08 +0100
Subject: [PATCH 230/493] make naming less git related

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 132415a5a6..e10a4f8db3 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -51,7 +51,7 @@
         <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
     </div>
     <div class="col-md-6">
-        <h4>{% trans "Code information" %}</h4>
+        <h4>{% trans "Package information" %}</h4>
         <table class='table table-striped table-condensed'>
             <col width='25'>
             <tr>
@@ -72,12 +72,12 @@
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
-                <td>{% trans "Commit Sign Status" %}</td>
+                <td>{% trans "Sign Status" %}</td>
                 <td class="bg-{{plugin.sign_color}}">{% if plugin.package.verified %}{{ plugin.package.verified }}: {% endif%}{{ plugin.sign_state.msg }}</td>
             </tr>
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-key'></span></td>
-                <td>{% trans "Commit Sign Key" %}</td>
+                <td>{% trans "Sign Key" %}</td>
                 <td class="bg-{{plugin.sign_color}}">{{ plugin.package.key }}{% include "clip.html" %}</td>
             </tr>
         </table>

From 99c3bc552975469ddc7da0b234f538dc70893d2c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:21:02 +0100
Subject: [PATCH 231/493] make package info conditional

---
 .../templates/InvenTree/settings/plugin_settings.html     | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index e10a4f8db3..3aac7bf845 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -48,12 +48,15 @@
             {% endif %}
         </table>
 
+        {% if plugin.is_package==False %}
         <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
+        {% endif %}
     </div>
     <div class="col-md-6">
         <h4>{% trans "Package information" %}</h4>
         <table class='table table-striped table-condensed'>
             <col width='25'>
+            {% if plugin.is_package==False %}
             <tr>
                 <td><span class='fas fa-user'></span></td>
                 <td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>
@@ -70,6 +73,11 @@
                 <td><span class='fas fa-envelope'></span></td>
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.package.message }}{% include "clip.html" %}</td>
             </tr>
+            {% else %}
+            <tr>
+                <td>{% trans "This plugin was installed as a package" %}</td>
+            </tr>
+            {% endif %}
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>
                 <td>{% trans "Sign Status" %}</td>

From 267c5ca40c7d8dcbf137d1e4be1222afcbae3305 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:30:40 +0100
Subject: [PATCH 232/493] show install method for plugin

---
 .../InvenTree/settings/plugin_settings.html   | 19 +++++++++++++------
 1 file changed, 13 insertions(+), 6 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 3aac7bf845..dc243899b9 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -48,7 +48,7 @@
             {% endif %}
         </table>
 
-        {% if plugin.is_package==False %}
+        {% if plugin.is_package == False %}
         <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
         {% endif %}
     </div>
@@ -56,7 +56,18 @@
         <h4>{% trans "Package information" %}</h4>
         <table class='table table-striped table-condensed'>
             <col width='25'>
-            {% if plugin.is_package==False %}
+            <tr>
+                <td></td>
+                <td>{% trans "Installation method" %}</td>
+                <td>
+                    {% if plugin.is_package %}
+                    {% trans "This plugin was installed as a package" %}
+                    {% else %}
+                    {% trans "This plugin was found in a local InvenTree path" %}
+                    {% endif %}
+                </td>
+            </tr>
+            {% if plugin.is_package == False %}
             <tr>
                 <td><span class='fas fa-user'></span></td>
                 <td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>
@@ -73,10 +84,6 @@
                 <td><span class='fas fa-envelope'></span></td>
                 <td>{% trans "Commit Message" %}</td><td>{{ plugin.package.message }}{% include "clip.html" %}</td>
             </tr>
-            {% else %}
-            <tr>
-                <td>{% trans "This plugin was installed as a package" %}</td>
-            </tr>
             {% endif %}
             <tr>
                 <td><span class='text-{{plugin.sign_color}} fas fa-check'></span></td>

From 80414ba6b59ed4baef0aa3530dce52c89adcbf05 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 13:44:25 +0100
Subject: [PATCH 233/493] make icon optional

---
 InvenTree/templates/sidebar_item.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/sidebar_item.html b/InvenTree/templates/sidebar_item.html
index d5c7b08365..c5f5c7647d 100644
--- a/InvenTree/templates/sidebar_item.html
+++ b/InvenTree/templates/sidebar_item.html
@@ -1,7 +1,7 @@
 {% load i18n %}
 <a href="#" id='select-{{ label }}' title='{% trans text %}' class="list-group-item sidebar-list-group-item border-end-0 d-inline-block text-truncate sidebar-selector" data-bs-parent="#sidebar">
     <i class="bi bi-bootstrap"></i>
-    <span class='sidebar-item-icon fas {{ icon }}'></span>
+    <span class='sidebar-item-icon{% if icon %} fas {{ icon }}{% endif %}'></span>
     <span class='sidebar-item-text' style='display: none;'>{% trans text %}</span>
     {% if badge %}
     <span id='sidebar-badge-{{ label }}' class='sidebar-item-badge badge rounded-pill badge-right bg-dark'>

From 2992dfdfed270cb7bdd317b7d9ca028d453da26a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 14:21:17 +0100
Subject: [PATCH 234/493] remove Shopify

---
 InvenTree/plugins/ShopifyIntegrationPlugin | 1 -
 1 file changed, 1 deletion(-)
 delete mode 160000 InvenTree/plugins/ShopifyIntegrationPlugin

diff --git a/InvenTree/plugins/ShopifyIntegrationPlugin b/InvenTree/plugins/ShopifyIntegrationPlugin
deleted file mode 160000
index 6a9d1e622e..0000000000
--- a/InvenTree/plugins/ShopifyIntegrationPlugin
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 6a9d1e622e6e64208df58c6a75a4a1caf12b20bf

From 1ac51afd2746a47ba00bad052edae52d5ee1c95d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 14:23:42 +0100
Subject: [PATCH 235/493] revert unneeded change

---
 .github/workflows/mysql.yaml | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/.github/workflows/mysql.yaml b/.github/workflows/mysql.yaml
index 7c8638d49f..f0eb0efd7f 100644
--- a/.github/workflows/mysql.yaml
+++ b/.github/workflows/mysql.yaml
@@ -55,8 +55,7 @@ jobs:
           pip3 install mysqlclient
           invoke install
       - name: Run Tests
-        run: |
-          invoke test
+        run: invoke test
       - name: Data Import Export
         run: |
           invoke migrate

From f49800d5b88330e19a719dfe27ff2dd434e6ffbf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 4 Nov 2021 14:54:15 +0100
Subject: [PATCH 236/493] make attr - test safer

---
 InvenTree/plugin/integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 10b3a3a0a6..b5b84b6ed0 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -356,7 +356,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
     def set_package(self):
         """add packaging info of the plugins into plugins context"""
-        package = self.get_package_metadata() if self.is_package else self.get_package_commit()
+        package = self.get_package_metadata() if getattr(self, 'is_package', False) else self.get_package_commit()
 
         # process date
         if package.get('date'):

From ab1742da641b3a13f2cce73609ff54cb718beb81 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 12:19:11 +0100
Subject: [PATCH 237/493] remove reduntant block

---
 InvenTree/templates/InvenTree/settings/settings.html | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index 85116996b9..8b625c2eff 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -16,9 +16,6 @@
 {% include "InvenTree/settings/sidebar.html" %}
 {% endblock %}
 
-{% block breadcrumb_list %}
-{% endblock %}
-
 {% block content %}
 
 {% include "InvenTree/settings/user.html" %}

From 112e04381e89f110932637a8087e5576229e33b5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 12:20:22 +0100
Subject: [PATCH 238/493] fix badges

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index b13e075fab..666fdd4a47 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -50,7 +50,7 @@
                 {% if mixin_list %}
                 {% for mixin in mixin_list %}
                 <a class='nav-toggle' id='select-plugin-{{plugin_key}}'>
-                    <span class='badge badge-badge-secondary'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span>
+                    <span class='badge bg-dark badge-right'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span>
                 </a>
                 {% endfor %}
                 {% endif %}

From 36591a5f6e4add0392cc218334140ea65afe0ea8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 12:33:11 +0100
Subject: [PATCH 239/493] fix link

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 666fdd4a47..76d868a093 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -49,7 +49,7 @@
 
                 {% if mixin_list %}
                 {% for mixin in mixin_list %}
-                <a class='nav-toggle' id='select-plugin-{{plugin_key}}'>
+                <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
                     <span class='badge bg-dark badge-right'>{% blocktrans with name=mixin.human_name %}has {{name}}{% endblocktrans %}</span>
                 </a>
                 {% endfor %}

From fa36bcdbca22153e58066678c7bd46426d945a85 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 13:00:20 +0100
Subject: [PATCH 240/493] 'safe' loading of links

---
 InvenTree/part/templatetags/plugin_extras.py | 10 ++++++++++
 InvenTree/templates/navbar.html              |  5 ++++-
 2 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/part/templatetags/plugin_extras.py
index c5e86a6900..51a115b67e 100644
--- a/InvenTree/part/templatetags/plugin_extras.py
+++ b/InvenTree/part/templatetags/plugin_extras.py
@@ -4,6 +4,7 @@
 """
 from django.conf import settings as djangosettings
 from django import template
+from django.urls import reverse
 
 from common.models import InvenTreeSetting
 
@@ -35,3 +36,12 @@ def navigation_enabled(*args, **kwargs):
     if djangosettings.TESTING:
         return True
     return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION')
+
+
+@register.simple_tag()
+def safe_url(view_name, *args, **kwargs):
+    """ safe lookup for urls """
+    try:
+        return reverse(view_name, args=args, kwargs=kwargs)
+    except:
+        return None
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index cd2a2a0a56..02f7b2e6b7 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -70,7 +70,10 @@
             </a>
             <ul class='dropdown-menu'>
              {% for nav_item in plugin.navigation %}
-                <li><a href="{% url nav_item.link %}" class="dropdown-item"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
+                {% safe_url nav_item.link as nav_link %}
+                {% if nav_link %}
+                <li><a href="{{ nav_link }}" class="dropdown-item"><span class='{{nav_item.icon}} icon-header'></span>{{nav_item.name}}</a>
+                {% endif %}
              {% endfor %}
             </ul>
           </li>

From 796b2a059dcbf8a3483f00f4e97ba9c1cf8f819b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 13:10:12 +0100
Subject: [PATCH 241/493] move tempalte tags to plugin

---
 InvenTree/{part => plugin}/templatetags/plugin_extras.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename InvenTree/{part => plugin}/templatetags/plugin_extras.py (100%)

diff --git a/InvenTree/part/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
similarity index 100%
rename from InvenTree/part/templatetags/plugin_extras.py
rename to InvenTree/plugin/templatetags/plugin_extras.py

From 99f65d242e32451a885fae3cf43c193ec21be3c5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 13:25:07 +0100
Subject: [PATCH 242/493] fix test path

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 8ff47987b3..4257fbeb64 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -8,7 +8,7 @@ import plugin.integration
 from plugin.samples.integration.sample import SampleIntegrationPlugin
 from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
 from plugin.plugins import load_integration_plugins  # , load_action_plugins, load_barcode_plugins
-import part.templatetags.plugin_extras as plugin_tags
+import plugin.templatetags.plugin_extras as plugin_tags
 
 
 class InvenTreePluginTests(TestCase):

From 689b414d6244253e10af36cd8ec8cce1704491e0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 23:32:22 +0100
Subject: [PATCH 243/493] show info if no version exists Fixes #2295

---
 .../templates/InvenTree/settings/plugin_settings.html  | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index dc243899b9..c9ee40ffd9 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -32,13 +32,17 @@
                 <td>{% trans "Date" %}</td>
                 <td>{{ plugin.pub_date }}{% include "clip.html" %}</td>
             </tr>
-            {% if plugin.version %}
             <tr>
                 <td><span class='fas fa-hashtag'></span></td>
                 <td>{% trans "Version" %}</td>
-                <td>{{ plugin.version }}{% include "clip.html" %}</td>
+                <td>
+                    {% if plugin.version %}
+                        {{ plugin.version }}{% include "clip.html" %}
+                    {% else %}
+                        {% trans 'no version information supplied' %}
+                    {% endif %}
+                </td>
             </tr>
-            {% endif %}
             {% if plugin.website %}
             <tr>
                 <td><span class='fas fa-globe'></span></td>

From 1535fc0565225b28533dc457e9766add50b68a31 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 11 Nov 2021 23:55:34 +0100
Subject: [PATCH 244/493] refactor is_package

---
 InvenTree/plugin/integration.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index b5b84b6ed0..1b8b70e3bd 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -282,6 +282,10 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
         self.set_package()
 
+    @property
+    def _is_package(self):
+        return getattr(self, 'is_package', False)
+
     # properties
     @property
     def slug(self):
@@ -356,7 +360,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
 
     def set_package(self):
         """add packaging info of the plugins into plugins context"""
-        package = self.get_package_metadata() if getattr(self, 'is_package', False) else self.get_package_commit()
+        package = self.get_package_metadata() if self._is_package else self.get_package_commit()
 
         # process date
         if package.get('date'):

From 65046df4173ae889002cf839e6b35cfdfdfb8d02 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 00:00:43 +0100
Subject: [PATCH 245/493] display path in plugin details Fixes #2294

---
 InvenTree/plugin/integration.py                           | 8 ++++++++
 .../templates/InvenTree/settings/plugin_settings.html     | 5 +++++
 2 files changed, 13 insertions(+)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 1b8b70e3bd..181576732c 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -6,6 +6,7 @@ import os
 import subprocess
 import inspect
 from datetime import datetime
+import pathlib
 
 from django.conf.urls import url, include
 from django.conf import settings
@@ -337,6 +338,13 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         name = getattr(self, 'WEBSITE', None)
         return name
 
+    @property
+    def package_path(self):
+        """returns the path to the plugin"""
+        if self._is_package:
+            return self.__module__
+        return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
+
     # mixins
     def mixin(self, key):
         """check if mixin is registered"""
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index c9ee40ffd9..e78e2f204a 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -71,6 +71,11 @@
                     {% endif %}
                 </td>
             </tr>
+            <tr>
+                <td></td>
+                <td>{% trans "Installation path" %}</td>
+                <td>{{ plugin.package_path }}</td>
+            </tr>
             {% if plugin.is_package == False %}
             <tr>
                 <td><span class='fas fa-user'></span></td>

From b2478b418ac670cad800c0b5e535f3fb31f55e6d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 00:09:05 +0100
Subject: [PATCH 246/493] "description" field for plugin Fixes #2293

---
 InvenTree/plugin/integration.py                          | 9 +++++++++
 InvenTree/plugin/test_integration.py                     | 6 ++++++
 .../templates/InvenTree/settings/plugin_settings.html    | 5 +++++
 3 files changed, 20 insertions(+)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 181576732c..0aaecfd28c 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -271,6 +271,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     PLUGIN_TITLE = None
 
     AUTHOR = None
+    DESCRIPTION = None
     PUBLISH_DATE = None
     VERSION = None
     WEBSITE = None
@@ -304,6 +305,14 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             name = self.plugin_name()
         return name
 
+    @property
+    def description(self):
+        """description of plugin"""
+        description = getattr(self, 'DESCRIPTION', None)
+        if not description:
+            description = self.plugin_name()
+        return description
+
     @property
     def author(self):
         """returns author of plugin - either from plugin settings or git"""
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 53652b7a6f..9493d2a237 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -163,6 +163,7 @@ class IntegrationPluginBaseTests(TestCase):
             PLUGIN_TITLE = 'a titel'
             PUBLISH_DATE = "1111-11-11"
             AUTHOR = 'AA BB'
+            DESCRIPTION = 'A description'
             VERSION = '1.2.3a'
             WEBSITE = 'http://aa.bb/cc'
 
@@ -185,6 +186,11 @@ class IntegrationPluginBaseTests(TestCase):
         self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
         self.assertEqual(self.plugin_name.human_name, 'a titel')
 
+        # description
+        self.assertEqual(self.plugin.description, '')
+        self.assertEqual(self.plugin_simple.description, 'SimplePlugin')
+        self.assertEqual(self.plugin_name.description, 'A description')
+
         # author
         self.assertEqual(self.plugin_name.author, 'AA BB')
 
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index e78e2f204a..cb5ed85d6d 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -27,6 +27,11 @@
                 <td>{% trans "Author" %}</td>
                 <td>{{ plugin.author }}{% include "clip.html" %}</td>
             </tr>
+            <tr>
+                <td></td>
+                <td>{% trans "Description" %}</td>
+                <td>{{ plugin.description }}{% include "clip.html" %}</td>
+            </tr>
             <tr>
                 <td><span class='fas fa-calendar-alt'></span></td>
                 <td>{% trans "Date" %}</td>

From 61f6061edfc1a41a5782ed1308500eb13393669e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 00:12:03 +0100
Subject: [PATCH 247/493] adding in license meta

---
 InvenTree/plugin/integration.py                            | 7 +++++++
 InvenTree/plugin/test_integration.py                       | 6 ++++++
 .../templates/InvenTree/settings/plugin_settings.html      | 7 +++++++
 3 files changed, 20 insertions(+)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 0aaecfd28c..8d56d63db7 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -275,6 +275,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     PUBLISH_DATE = None
     VERSION = None
     WEBSITE = None
+    LICENSE = None
 
     def __init__(self):
         super().__init__()
@@ -347,6 +348,12 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         name = getattr(self, 'WEBSITE', None)
         return name
 
+    @property
+    def license(self):
+        """returns license of plugin"""
+        license = getattr(self, 'LICENSE', None)
+        return license
+
     @property
     def package_path(self):
         """returns the path to the plugin"""
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 9493d2a237..3befdf6b00 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -166,6 +166,7 @@ class IntegrationPluginBaseTests(TestCase):
             DESCRIPTION = 'A description'
             VERSION = '1.2.3a'
             WEBSITE = 'http://aa.bb/cc'
+            LICENSE = 'MIT'
 
         self.plugin_name = NameIntegrationPluginBase()
 
@@ -206,3 +207,8 @@ class IntegrationPluginBaseTests(TestCase):
         self.assertEqual(self.plugin.website, None)
         self.assertEqual(self.plugin_simple.website, None)
         self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc')
+
+        # license
+        self.assertEqual(self.plugin.license, None)
+        self.assertEqual(self.plugin_simple.license, None)
+        self.assertEqual(self.plugin_name.license, 'MIT')
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index cb5ed85d6d..22e81e1873 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -55,6 +55,13 @@
                 <td>{{ plugin.website }}{% include "clip.html" %}</td>
             </tr>
             {% endif %}
+            {% if plugin.license %}
+            <tr>
+                <td><span class='fas fa-balance-scale'></span></td>
+                <td>{% trans "License" %}</td>
+                <td>{{ plugin.license }}{% include "clip.html" %}</td>
+            </tr>
+            {% endif %}
         </table>
 
         {% if plugin.is_package == False %}

From ae5031e997dbd3cb479d9638fc11c2f6e6017c6d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 00:13:12 +0100
Subject: [PATCH 248/493] refactor internal names

---
 InvenTree/plugin/integration.py | 50 ++++++++++++++++-----------------
 1 file changed, 25 insertions(+), 25 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 8d56d63db7..da3c0ed18b 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -293,18 +293,18 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     @property
     def slug(self):
         """slug for the plugin"""
-        name = getattr(self, 'PLUGIN_SLUG', None)
-        if not name:
-            name = self.plugin_name()
-        return slugify(name)
+        slug = getattr(self, 'PLUGIN_SLUG', None)
+        if not slug:
+            slug = self.plugin_name()
+        return slugify(slug)
 
     @property
     def human_name(self):
         """human readable name for labels etc."""
-        name = getattr(self, 'PLUGIN_TITLE', None)
-        if not name:
-            name = self.plugin_name()
-        return name
+        human_name = getattr(self, 'PLUGIN_TITLE', None)
+        if not human_name:
+            human_name = self.plugin_name()
+        return human_name
 
     @property
     def description(self):
@@ -317,36 +317,36 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     @property
     def author(self):
         """returns author of plugin - either from plugin settings or git"""
-        name = getattr(self, 'AUTHOR', None)
-        if not name:
-            name = self.package.get('author')
-        if not name:
-            name = _('No author found')
-        return name
+        author = getattr(self, 'AUTHOR', None)
+        if not author:
+            author = self.package.get('author')
+        if not author:
+            author = _('No author found')
+        return author
 
     @property
     def pub_date(self):
         """returns publishing date of plugin - either from plugin settings or git"""
-        name = getattr(self, 'PUBLISH_DATE', None)
-        if not name:
-            name = self.package.get('date')
+        pub_date = getattr(self, 'PUBLISH_DATE', None)
+        if not pub_date:
+            pub_date = self.package.get('date')
         else:
-            name = datetime.fromisoformat(str(name))
-        if not name:
-            name = _('No date found')
-        return name
+            pub_date = datetime.fromisoformat(str(pub_date))
+        if not pub_date:
+            pub_date = _('No date found')
+        return pub_date
 
     @property
     def version(self):
         """returns version of plugin"""
-        name = getattr(self, 'VERSION', None)
-        return name
+        version = getattr(self, 'VERSION', None)
+        return version
 
     @property
     def website(self):
         """returns website of plugin"""
-        name = getattr(self, 'WEBSITE', None)
-        return name
+        website = getattr(self, 'WEBSITE', None)
+        return website
 
     @property
     def license(self):

From 685d3df6d19a46b4bb614a5d068c223b65ddfab6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 00:46:47 +0100
Subject: [PATCH 249/493] Enable / Disable Plugins Fixes #2292

---
 InvenTree/plugin/admin.py                   |  9 +++++
 InvenTree/plugin/migrations/0001_initial.py | 23 +++++++++++
 InvenTree/plugin/migrations/__init__.py     |  0
 InvenTree/plugin/models.py                  | 42 +++++++++++++++++++++
 4 files changed, 74 insertions(+)
 create mode 100644 InvenTree/plugin/admin.py
 create mode 100644 InvenTree/plugin/migrations/0001_initial.py
 create mode 100644 InvenTree/plugin/migrations/__init__.py
 create mode 100644 InvenTree/plugin/models.py

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
new file mode 100644
index 0000000000..69ac29a679
--- /dev/null
+++ b/InvenTree/plugin/admin.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.contrib import admin
+
+import plugin.models as models
+
+
+admin.site.register(models.PluginConfig, admin.ModelAdmin)
diff --git a/InvenTree/plugin/migrations/0001_initial.py b/InvenTree/plugin/migrations/0001_initial.py
new file mode 100644
index 0000000000..1dd7032f69
--- /dev/null
+++ b/InvenTree/plugin/migrations/0001_initial.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.5 on 2021-11-11 23:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PluginConfig',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('key', models.CharField(help_text='Key of plugin', max_length=255, unique=True, verbose_name='Key')),
+                ('name', models.CharField(blank=True, help_text='PluginName of the plugin', max_length=255, null=True, verbose_name='Name')),
+                ('active', models.BooleanField(default=False, help_text='Is the plugin active', verbose_name='Active')),
+            ],
+        ),
+    ]
diff --git a/InvenTree/plugin/migrations/__init__.py b/InvenTree/plugin/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
new file mode 100644
index 0000000000..2aa587d8a4
--- /dev/null
+++ b/InvenTree/plugin/models.py
@@ -0,0 +1,42 @@
+"""
+Plugin model definitions
+"""
+
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.utils.translation import gettext_lazy as _
+from django.db import models
+
+
+class PluginConfig(models.Model):
+    """ A PluginConfig object holds settings for plugins.
+
+    It is used to designate a Part as 'subscribed' for a given User.
+
+    Attributes:
+        key: slug of the plugin - must be unique
+        name: PluginName of the plugin - serves for a manual double check  if the right plugin is used
+        active: Should the plugin be loaded?
+    """
+
+    key = models.CharField(
+        unique=True,
+        max_length=255,
+        verbose_name=_('Key'),
+        help_text=_('Key of plugin'),
+    )
+
+    name = models.CharField(
+        null=True,
+        blank=True,
+        max_length=255,
+        verbose_name=_('Name'),
+        help_text=_('PluginName of the plugin'),
+    )
+
+    active = models.BooleanField(
+        default=False,
+        verbose_name=_('Active'),
+        help_text=_('Is the plugin active'),
+    )

From 8ef7a813ecc6ebf5a2e635c4d0b049fb4b1906de Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 01:07:18 +0100
Subject: [PATCH 250/493] refactor to plugin app config

---
 InvenTree/InvenTree/settings.py | 34 --------------------------------
 InvenTree/plugin/apps.py        | 35 +++++++++++++++++++++++++++++++++
 2 files changed, 35 insertions(+), 34 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index a9401a3688..44f3b3cf48 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -19,24 +19,14 @@ import socket
 import string
 import shutil
 import sys
-import importlib
 from datetime import datetime
 
-try:
-    from importlib import metadata
-except:
-    import importlib_metadata as metadata
-
-
 import moneyed
 
 import yaml
 from django.utils.translation import gettext_lazy as _
 from django.contrib.messages import constants as messages
 
-from plugin import plugins as inventree_plugins
-from plugin.integration import IntegrationPluginBase
-
 
 def _is_true(x):
     # Shortcut function to determine if a value "looks" like a boolean
@@ -869,31 +859,7 @@ if not TESTING:
 if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
-# collect all plugins from paths
 PLUGINS = []
-for plugin in PLUGIN_DIRS:
-    modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
-    if modules:
-        [PLUGINS.append(item) for item in modules]
-
-# Get plugins from setup entry points
-for entry in metadata.entry_points().get('inventree_plugins', []):
-    plugin = entry.load()
-    plugin.is_package = True
-    PLUGINS.append(plugin)
-
-# collect integration plugins
 INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
-
-for plugin in inventree_plugins.load_integration_plugins():
-    # check if package
-    was_packaged = getattr(plugin, 'is_package', False)
-
-    # init package
-    plugin.is_package = was_packaged
-    plugin = plugin()
-    plugin.is_package = was_packaged
-    # safe reference
-    INTEGRATION_PLUGINS[plugin.slug] = plugin
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index c6968ff90b..b3b9330676 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
+import importlib
 import pathlib
 from typing import OrderedDict
 
@@ -7,12 +8,46 @@ from django.apps import AppConfig, apps
 from django.conf import settings
 from django.db.utils import OperationalError, ProgrammingError
 
+try:
+    from importlib import metadata
+except:
+    import importlib_metadata as metadata
+
+from plugin import plugins as inventree_plugins
+from plugin.integration import IntegrationPluginBase
+
 
 class PluginConfig(AppConfig):
     name = 'plugin'
 
     def ready(self):
         from common.models import InvenTreeSetting
+
+        # Collect plugins from paths
+        for plugin in settings.PLUGIN_DIRS:
+            modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
+            if modules:
+                [settings.PLUGINS.append(item) for item in modules]
+
+        # Collect plugins from setup entry points
+        for entry in metadata.entry_points().get('inventree_plugins', []):
+            plugin = entry.load()
+            plugin.is_package = True
+            settings.PLUGINS.append(plugin)
+
+        # Initialize integration plugins
+        for plugin in inventree_plugins.load_integration_plugins():
+            # check if package
+            was_packaged = getattr(plugin, 'is_package', False)
+
+            # init package
+            plugin.is_package = was_packaged
+            plugin = plugin()
+            plugin.is_package = was_packaged
+            # safe reference
+            settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+
+        # activate integrations
         plugins = settings.INTEGRATION_PLUGINS.items()
 
         try:

From 36c0fad8e1ef7cb580446ba4e6529bce7558e656 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 01:12:13 +0100
Subject: [PATCH 251/493] check if plugin is enabled

---
 InvenTree/plugin/apps.py | 21 +++++++++++++++------
 1 file changed, 15 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b3b9330676..f045ed6d16 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -22,6 +22,7 @@ class PluginConfig(AppConfig):
 
     def ready(self):
         from common.models import InvenTreeSetting
+        from plugin.models import PluginConfig
 
         # Collect plugins from paths
         for plugin in settings.PLUGIN_DIRS:
@@ -40,12 +41,20 @@ class PluginConfig(AppConfig):
             # check if package
             was_packaged = getattr(plugin, 'is_package', False)
 
-            # init package
-            plugin.is_package = was_packaged
-            plugin = plugin()
-            plugin.is_package = was_packaged
-            # safe reference
-            settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+            # 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
+            plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
+
+            if plugin_db_setting.active:
+                # init package
+                # now we can be sure that an admin has activated the plugin -> 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.
+                plugin = plugin()
+                plugin.is_package = was_packaged
+                # safe reference
+                settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
 
         # activate integrations
         plugins = settings.INTEGRATION_PLUGINS.items()

From 4dc1ae4f5fa82da952e12ef8df22a0385dcd6b69 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 01:22:35 +0100
Subject: [PATCH 252/493] log stages

---
 InvenTree/plugin/apps.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index f045ed6d16..7f2af4687b 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 import importlib
 import pathlib
+import logging
 from typing import OrderedDict
 
 from django.apps import AppConfig, apps
@@ -16,6 +17,8 @@ except:
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
 
+logger = logging.getLogger('inventree')
+
 
 class PluginConfig(AppConfig):
     name = 'plugin'
@@ -36,6 +39,10 @@ class PluginConfig(AppConfig):
             plugin.is_package = True
             settings.PLUGINS.append(plugin)
 
+        # Log found plugins
+        logger.info(f'Found {len(settings.PLUGINS)} plugins!')
+        logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
+
         # Initialize integration plugins
         for plugin in inventree_plugins.load_integration_plugins():
             # check if package
@@ -51,7 +58,9 @@ class PluginConfig(AppConfig):
                 # init package
                 # now we can be sure that an admin has activated the plugin -> 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.
+                logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
                 plugin = plugin()
+                logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged
                 # safe reference
                 settings.INTEGRATION_PLUGINS[plugin.slug] = plugin

From b706ed23121b87df5bb8aeed19f672c003026369 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 01:54:03 +0100
Subject: [PATCH 253/493] keep inactive plugins

---
 InvenTree/InvenTree/settings.py | 1 +
 InvenTree/plugin/apps.py        | 3 +++
 2 files changed, 4 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 44f3b3cf48..6a5b3f9e8c 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -861,5 +861,6 @@ if DEBUG or TESTING:
 
 PLUGINS = []
 INTEGRATION_PLUGINS = {}
+INTEGRATION_PLUGINS_INACTIVE = {}
 INTEGRATION_PLUGIN_SETTING = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 7f2af4687b..b8c7da1b40 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -64,6 +64,9 @@ class PluginConfig(AppConfig):
                 plugin.is_package = was_packaged
                 # safe reference
                 settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+            else:
+                # save for later reference
+                settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
 
         # activate integrations
         plugins = settings.INTEGRATION_PLUGINS.items()

From 55b4ba6207a046a7d0c12b113da13ba164c3bdff Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 01:54:35 +0100
Subject: [PATCH 254/493] show inactive plugins in ui

---
 InvenTree/plugin/templatetags/plugin_extras.py     |  6 ++++++
 InvenTree/templates/InvenTree/settings/plugin.html | 12 ++++++++++++
 2 files changed, 18 insertions(+)

diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 51a115b67e..56d50fab01 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -18,6 +18,12 @@ def plugin_list(*args, **kwargs):
     return djangosettings.INTEGRATION_PLUGINS
 
 
+@register.simple_tag()
+def inactive_plugin_list(*args, **kwargs):
+    """ Return a list of all inactive integration plugins """
+    return djangosettings.INTEGRATION_PLUGINS_INACTIVE
+
+
 @register.simple_tag()
 def plugin_settings(plugin, *args, **kwargs):
     """ Return a list of all settings for a plugin """
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 76d868a093..f5c5054976 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -64,6 +64,18 @@
             <td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td>
         </tr>
         {% endfor %}
+
+        {% inactive_plugin_list as in_pl_list %}
+        {% if in_pl_list %}
+        <tr><td colspan="4"></td></tr>
+        <tr><td colspan="4"><h6>{% trans 'Inactiv plugins' %}</h6></td></tr>
+        {% for plugin_key, plugin in in_pl_list.items %}
+        <tr>
+            <td>{{plugin.name}}<span class="text-muted"> - {{plugin.key}}</span></td>
+            <td colspan="3"></td>
+        </tr>
+        {% endfor %}
+        {% endif %}
     </tbody>
 </table>
 

From 6de0a211f76ab994620ebd18dbe4db30aec1cccc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:04:52 +0100
Subject: [PATCH 255/493] catch if db not migrated

---
 InvenTree/plugin/apps.py | 60 +++++++++++++++++++++-------------------
 1 file changed, 32 insertions(+), 28 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b8c7da1b40..bd45ddd359 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -43,37 +43,40 @@ class PluginConfig(AppConfig):
         logger.info(f'Found {len(settings.PLUGINS)} plugins!')
         logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
 
-        # Initialize integration plugins
-        for plugin in inventree_plugins.load_integration_plugins():
-            # 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!!
-            plug_name = plugin.PLUGIN_NAME
-            plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
-            plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
-
-            if plugin_db_setting.active:
-                # init package
-                # now we can be sure that an admin has activated the plugin -> 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.
-                logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
-                plugin = plugin()
-                logger.info(f'Loaded integration plugin {plugin.slug}')
-                plugin.is_package = was_packaged
-                # safe reference
-                settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
-            else:
-                # save for later reference
-                settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
-
-        # activate integrations
-        plugins = settings.INTEGRATION_PLUGINS.items()
-
         try:
+            logger.info('Starting plugin initialisation')
+            # Initialize integration plugins
+            for plugin in inventree_plugins.load_integration_plugins():
+                # 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!!
+                plug_name = plugin.PLUGIN_NAME
+                plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
+                plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
+
+                if plugin_db_setting.active:
+                    # init package
+                    # now we can be sure that an admin has activated the plugin -> 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.
+                    logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
+                    plugin = plugin()
+                    logger.info(f'Loaded integration plugin {plugin.slug}')
+                    plugin.is_package = was_packaged
+                    # safe reference
+                    settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+                else:
+                    # save for later reference
+                    settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+
+            # activate integrations
+            plugins = settings.INTEGRATION_PLUGINS.items()
+            logger.info(f'Found {len(plugins)} active plugins')
+
             # if plugin settings are enabled enhance the settings
             if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+                logger.info('Registering IntegrationPlugin settings')
                 for slug, plugin in plugins:
                     if plugin.mixin_enabled('settings'):
                         plugin_setting = plugin.settingspatterns
@@ -84,6 +87,7 @@ class PluginConfig(AppConfig):
 
             # if plugin apps are enabled
             if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+                logger.info('Registering IntegrationPlugin apps')
                 settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
                 apps_changed = False
 

From 6e34119f852a6c221e86823560f1ffa21b9df3c0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:05:41 +0100
Subject: [PATCH 256/493] nicer model name

---
 InvenTree/plugin/models.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 2aa587d8a4..1749ea8cd8 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -40,3 +40,9 @@ class PluginConfig(models.Model):
         verbose_name=_('Active'),
         help_text=_('Is the plugin active'),
     )
+
+    def __str__(self) -> str:
+        name = f'{self.name} - {self.key}'
+        if not self.active:
+            name += '(not active)'
+        return name

From 175b6ca0533ebf5c89bcc9dd768d01bd89a075c5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:14:49 +0100
Subject: [PATCH 257/493] admin buttons for plugins

---
 .../templates/InvenTree/settings/plugin.html  | 22 ++++++++++++++++---
 1 file changed, 19 insertions(+), 3 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index f5c5054976..adfb349251 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -25,11 +25,15 @@
     </tbody>
 </table>
 
-<h4>{% trans "Plugin list" %}</h4>
+<h4>{% trans "Plugin list" %}
+    {% url 'admin:plugin_pluginconfig' as url %}
+    {% include "admin_button.html" with url=url %}
+</h4>
 
 <table class='table table-striped table-condensed'>
     <thead>
         <tr>
+            <th>{% trans "Admin" %}</th>
             <th>{% trans "Name" %}</th>
             <th>{% trans "Author" %}</th>
             <th>{% trans "Date" %}</th>
@@ -44,6 +48,12 @@
         {% mixin_enabled plugin 'settings' as settings %}
 
         <tr>
+            <td>
+                {% if user.is_staff and perms.plugin.change_pluginconfig %}
+                {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %}
+                {% include "admin_button.html" with url=url %}
+                {% endif %}
+            </td>
             <td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
                 {% define plugin.registered_mixins as mixin_list %}
 
@@ -67,10 +77,16 @@
 
         {% inactive_plugin_list as in_pl_list %}
         {% if in_pl_list %}
-        <tr><td colspan="4"></td></tr>
-        <tr><td colspan="4"><h6>{% trans 'Inactiv plugins' %}</h6></td></tr>
+        <tr><td colspan="5"></td></tr>
+        <tr><td colspan="5"><h6>{% trans 'Inactiv plugins' %}</h6></td></tr>
         {% for plugin_key, plugin in in_pl_list.items %}
         <tr>
+            <td>
+                {% if user.is_staff and perms.plugin.change_pluginconfig %}
+                {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %}
+                {% include "admin_button.html" with url=url %}
+                {% endif %}
+            </td>
             <td>{{plugin.name}}<span class="text-muted"> - {{plugin.key}}</span></td>
             <td colspan="3"></td>
         </tr>

From 0e6f203660f255de888a204ca9a1cf5f2c08088f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:26:10 +0100
Subject: [PATCH 258/493] refactor plugin loading

---
 InvenTree/InvenTree/settings.py |   2 +-
 InvenTree/plugin/apps.py        | 159 +++++++++++++++++---------------
 2 files changed, 87 insertions(+), 74 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 6a5b3f9e8c..a2b1de5527 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -262,7 +262,7 @@ INSTALLED_APPS = [
     'report.apps.ReportConfig',
     'stock.apps.StockConfig',
     'users.apps.UsersConfig',
-    'plugin.apps.PluginConfig',
+    'plugin.apps.PluginAppConfig',
     'InvenTree.apps.InvenTreeConfig',       # InvenTree app runs last
 
     # Third part add-ons
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index bd45ddd359..4278ed56c8 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -20,13 +20,98 @@ from plugin.integration import IntegrationPluginBase
 logger = logging.getLogger('inventree')
 
 
-class PluginConfig(AppConfig):
+class PluginAppConfig(AppConfig):
     name = 'plugin'
 
     def ready(self):
+        self.collect_plugins()
+
+        try:
+            # we are using the db from here - so for migrations etc we need to try this block
+            self.init_plugins()
+            self.activate_plugins()
+        except (OperationalError, ProgrammingError):
+            # Exception if the database has not been migrated yet
+            pass
+
+    def activate_plugins(self):
+        """fullfill integrations for all activated plugins"""
         from common.models import InvenTreeSetting
+
+        # activate integrations
+        plugins = settings.INTEGRATION_PLUGINS.items()
+        logger.info(f'Found {len(plugins)} active plugins')
+
+            # if plugin settings are enabled enhance the settings
+        if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+            logger.info('Registering IntegrationPlugin settings')
+            for slug, plugin in plugins:
+                if plugin.mixin_enabled('settings'):
+                    plugin_setting = plugin.settingspatterns
+                    settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
+
+                        # Add to settings dir
+                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
+
+            # if plugin apps are enabled
+        if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+            logger.info('Registering IntegrationPlugin apps')
+            settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
+            apps_changed = False
+
+                # add them to the INSTALLED_APPS
+            for slug, plugin in plugins:
+                if plugin.mixin_enabled('app'):
+                    try:
+                            # for local path plugins
+                        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+                    except ValueError:
+                            # plugin is shipped as package
+                        plugin_path = plugin.PLUGIN_NAME
+                    if plugin_path not in settings.INSTALLED_APPS:
+                        settings.INSTALLED_APPS += [plugin_path]
+                        apps_changed = True
+
+                # if apps were changed reload
+                # TODO this is a bit jankey to be honest
+            if apps_changed:
+                apps.app_configs = OrderedDict()
+                apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+                apps.clear_cache()
+                apps.populate(settings.INSTALLED_APPS)
+
+    def init_plugins(self):
+        """initialise all found plugins"""
         from plugin.models import PluginConfig
 
+        logger.info('Starting plugin initialisation')
+            # Initialize integration plugins
+        for plugin in inventree_plugins.load_integration_plugins():
+                # 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!!
+            plug_name = plugin.PLUGIN_NAME
+            plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
+            plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
+
+            if plugin_db_setting.active:
+                    # init package
+                    # now we can be sure that an admin has activated the plugin -> 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.
+                logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
+                plugin = plugin()
+                logger.info(f'Loaded integration plugin {plugin.slug}')
+                plugin.is_package = was_packaged
+                    # safe reference
+                settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+            else:
+                    # save for later reference
+                settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+
+    def collect_plugins(self):
+        """collect integration plugins from all possible ways of loading"""
         # Collect plugins from paths
         for plugin in settings.PLUGIN_DIRS:
             modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
@@ -42,75 +127,3 @@ class PluginConfig(AppConfig):
         # Log found plugins
         logger.info(f'Found {len(settings.PLUGINS)} plugins!')
         logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
-
-        try:
-            logger.info('Starting plugin initialisation')
-            # Initialize integration plugins
-            for plugin in inventree_plugins.load_integration_plugins():
-                # 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!!
-                plug_name = plugin.PLUGIN_NAME
-                plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
-                plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
-
-                if plugin_db_setting.active:
-                    # init package
-                    # now we can be sure that an admin has activated the plugin -> 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.
-                    logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
-                    plugin = plugin()
-                    logger.info(f'Loaded integration plugin {plugin.slug}')
-                    plugin.is_package = was_packaged
-                    # safe reference
-                    settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
-                else:
-                    # save for later reference
-                    settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
-
-            # activate integrations
-            plugins = settings.INTEGRATION_PLUGINS.items()
-            logger.info(f'Found {len(plugins)} active plugins')
-
-            # if plugin settings are enabled enhance the settings
-            if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
-                logger.info('Registering IntegrationPlugin settings')
-                for slug, plugin in plugins:
-                    if plugin.mixin_enabled('settings'):
-                        plugin_setting = plugin.settingspatterns
-                        settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
-
-                        # Add to settings dir
-                        InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
-
-            # if plugin apps are enabled
-            if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
-                logger.info('Registering IntegrationPlugin apps')
-                settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
-                apps_changed = False
-
-                # add them to the INSTALLED_APPS
-                for slug, plugin in plugins:
-                    if plugin.mixin_enabled('app'):
-                        try:
-                            # for local path plugins
-                            plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-                        except ValueError:
-                            # plugin is shipped as package
-                            plugin_path = plugin.PLUGIN_NAME
-                        if plugin_path not in settings.INSTALLED_APPS:
-                            settings.INSTALLED_APPS += [plugin_path]
-                            apps_changed = True
-
-                # if apps were changed reload
-                # TODO this is a bit jankey to be honest
-                if apps_changed:
-                    apps.app_configs = OrderedDict()
-                    apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
-                    apps.clear_cache()
-                    apps.populate(settings.INSTALLED_APPS)
-        except (OperationalError, ProgrammingError):
-            # Exception if the database has not been migrated yet
-            pass

From aa0237723aeb6e5dd040581ebb7568b4fcd62ebd Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:32:17 +0100
Subject: [PATCH 259/493] refactor a bit more

---
 InvenTree/plugin/apps.py | 126 +++++++++++++++++++++------------------
 1 file changed, 67 insertions(+), 59 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 4278ed56c8..52e04e6cfc 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -29,56 +29,28 @@ class PluginAppConfig(AppConfig):
         try:
             # we are using the db from here - so for migrations etc we need to try this block
             self.init_plugins()
-            self.activate_plugins()
+            self.activate_integration()
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
             pass
 
-    def activate_plugins(self):
-        """fullfill integrations for all activated plugins"""
-        from common.models import InvenTreeSetting
+    def collect_plugins(self):
+        """collect integration plugins from all possible ways of loading"""
+        # Collect plugins from paths
+        for plugin in settings.PLUGIN_DIRS:
+            modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
+            if modules:
+                [settings.PLUGINS.append(item) for item in modules]
 
-        # activate integrations
-        plugins = settings.INTEGRATION_PLUGINS.items()
-        logger.info(f'Found {len(plugins)} active plugins')
+        # Collect plugins from setup entry points
+        for entry in metadata.entry_points().get('inventree_plugins', []):
+            plugin = entry.load()
+            plugin.is_package = True
+            settings.PLUGINS.append(plugin)
 
-            # if plugin settings are enabled enhance the settings
-        if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
-            logger.info('Registering IntegrationPlugin settings')
-            for slug, plugin in plugins:
-                if plugin.mixin_enabled('settings'):
-                    plugin_setting = plugin.settingspatterns
-                    settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
-
-                        # Add to settings dir
-                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
-
-            # if plugin apps are enabled
-        if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
-            logger.info('Registering IntegrationPlugin apps')
-            settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
-            apps_changed = False
-
-                # add them to the INSTALLED_APPS
-            for slug, plugin in plugins:
-                if plugin.mixin_enabled('app'):
-                    try:
-                            # for local path plugins
-                        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-                    except ValueError:
-                            # plugin is shipped as package
-                        plugin_path = plugin.PLUGIN_NAME
-                    if plugin_path not in settings.INSTALLED_APPS:
-                        settings.INSTALLED_APPS += [plugin_path]
-                        apps_changed = True
-
-                # if apps were changed reload
-                # TODO this is a bit jankey to be honest
-            if apps_changed:
-                apps.app_configs = OrderedDict()
-                apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
-                apps.clear_cache()
-                apps.populate(settings.INSTALLED_APPS)
+        # Log found plugins
+        logger.info(f'Found {len(settings.PLUGINS)} plugins!')
+        logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
 
     def init_plugins(self):
         """initialise all found plugins"""
@@ -110,20 +82,56 @@ class PluginAppConfig(AppConfig):
                     # save for later reference
                 settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
 
-    def collect_plugins(self):
-        """collect integration plugins from all possible ways of loading"""
-        # Collect plugins from paths
-        for plugin in settings.PLUGIN_DIRS:
-            modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
-            if modules:
-                [settings.PLUGINS.append(item) for item in modules]
+    def activate_integration(self):
+        """fullfill integrations for all activated plugins"""
+        # activate integrations
+        plugins = settings.INTEGRATION_PLUGINS.items()
+        logger.info(f'Found {len(plugins)} active plugins')
 
-        # Collect plugins from setup entry points
-        for entry in metadata.entry_points().get('inventree_plugins', []):
-            plugin = entry.load()
-            plugin.is_package = True
-            settings.PLUGINS.append(plugin)
+        # if plugin settings are enabled enhance the settings
+        self.activate_integration_settings(plugins)
 
-        # Log found plugins
-        logger.info(f'Found {len(settings.PLUGINS)} plugins!')
-        logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
+        # if plugin apps are enabled
+        self.activate_integration_app(plugins)
+
+    def activate_integration_settings(self, plugins):
+        from common.models import InvenTreeSetting
+
+        if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+            logger.info('Registering IntegrationPlugin settings')
+            for slug, plugin in plugins:
+                if plugin.mixin_enabled('settings'):
+                    plugin_setting = plugin.settingspatterns
+                    settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
+
+                    # Add to settings dir
+                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
+
+    def activate_integration_app(self, plugins):
+        from common.models import InvenTreeSetting
+
+        if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+            logger.info('Registering IntegrationPlugin apps')
+            settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
+            apps_changed = False
+
+                    # add them to the INSTALLED_APPS
+            for slug, plugin in plugins:
+                if plugin.mixin_enabled('app'):
+                    try:
+                                # for local path plugins
+                        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+                    except ValueError:
+                                # plugin is shipped as package
+                        plugin_path = plugin.PLUGIN_NAME
+                    if plugin_path not in settings.INSTALLED_APPS:
+                        settings.INSTALLED_APPS += [plugin_path]
+                        apps_changed = True
+
+                    # if apps were changed reload
+                    # TODO this is a bit jankey to be honest
+            if apps_changed:
+                apps.app_configs = OrderedDict()
+                apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+                apps.clear_cache()
+                apps.populate(settings.INSTALLED_APPS)

From 9ae8474ed906fee702dcd7d333426adc93108c85 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:34:18 +0100
Subject: [PATCH 260/493] fix test

---
 InvenTree/users/models.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 4599408163..ac04f788a9 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -73,6 +73,7 @@ class RuleSet(models.Model):
             'socialaccount_socialaccount',
             'socialaccount_socialapp',
             'socialaccount_socialtoken',
+            'plugin_pluginconfig'
         ],
         'part_category': [
             'part_partcategory',

From da7dd0a4ac58b56454108fde748407b4eab0fd21 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 02:39:06 +0100
Subject: [PATCH 261/493] PEP fix

---
 InvenTree/plugin/apps.py | 29 +++++++++++++++--------------
 1 file changed, 15 insertions(+), 14 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 52e04e6cfc..3d98a255d3 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -57,29 +57,30 @@ class PluginAppConfig(AppConfig):
         from plugin.models import PluginConfig
 
         logger.info('Starting plugin initialisation')
-            # Initialize integration plugins
+        # Initialize integration plugins
         for plugin in inventree_plugins.load_integration_plugins():
-                # 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
             plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
 
             if plugin_db_setting.active:
-                    # init package
-                    # now we can be sure that an admin has activated the plugin -> 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.
+                # init package
+                # now we can be sure that an admin has activated the plugin -> 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.
                 logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
                 plugin = plugin()
                 logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged
-                    # safe reference
+
+                # safe reference
                 settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
             else:
-                    # save for later reference
+                # save for later reference
                 settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
 
     def activate_integration(self):
@@ -115,21 +116,21 @@ class PluginAppConfig(AppConfig):
             settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
             apps_changed = False
 
-                    # add them to the INSTALLED_APPS
+            # add them to the INSTALLED_APPS
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('app'):
                     try:
-                                # for local path plugins
+                        # for local path plugins
                         plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
                     except ValueError:
-                                # plugin is shipped as package
+                        # plugin is shipped as package
                         plugin_path = plugin.PLUGIN_NAME
                     if plugin_path not in settings.INSTALLED_APPS:
                         settings.INSTALLED_APPS += [plugin_path]
                         apps_changed = True
 
-                    # if apps were changed reload
-                    # TODO this is a bit jankey to be honest
+            # if apps were changed reload
+            # TODO this is a bit jankey to be honest
             if apps_changed:
                 apps.app_configs = OrderedDict()
                 apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False

From c612cfcfbae7ecdc6352de25132b6400476eff43 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 08:03:25 +0100
Subject: [PATCH 262/493] mark restart required in the settings

---
 InvenTree/common/models.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 99c0637308..762589a1b0 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -970,24 +970,28 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'description': _('Enable plugins to add URL routes'),
             'default': False,
             'validator': bool,
+            'requires_restart': True,
         },
         'ENABLE_PLUGINS_NAVIGATION': {
             'name': _('Enable navigation integration'),
             'description': _('Enable plugins to integrate into navigation'),
             'default': False,
             'validator': bool,
+            'requires_restart': True,
         },
         'ENABLE_PLUGINS_SETTING': {
             'name': _('Enable setting integration'),
             'description': _('Enable plugins to integrate into inventree settings'),
             'default': False,
             'validator': bool,
+            'requires_restart': True,
         },
         'ENABLE_PLUGINS_APP': {
             'name': _('Enable app integration'),
             'description': _('Enable plugins to add apps'),
             'default': False,
             'validator': bool,
+            'requires_restart': True,
         },
     }
 

From 938f8bab2d6c1333fbb81ab4cf486dfa578fd79f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 12 Nov 2021 17:44:35 +0100
Subject: [PATCH 263/493] activate plugins before testing

---
 InvenTree/InvenTree/helpers.py                         | 10 ++++++++++
 .../samples/integration/test_samples_integration.py    |  5 +++++
 InvenTree/plugin/test_integration.py                   |  4 ++++
 3 files changed, 19 insertions(+)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 6ae31d6f5f..253f008715 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -22,6 +22,7 @@ import InvenTree.version
 from common.models import InvenTreeSetting
 from .settings import MEDIA_URL, STATIC_URL
 from common.settings import currency_code_default
+from plugin.models import PluginConfig
 
 from djmoney.money import Money
 
@@ -711,3 +712,12 @@ def inheritors(cls):
                 subcls.add(child)
                 work.append(child)
     return subcls
+
+
+def setup_plugin(plg_key, plg_name):
+    """
+    Enables plugins by reference
+    """
+    plg_setting, _ = PluginConfig.objects.get_or_create(key=plg_key, name=plg_name)
+    plg_setting.active = True
+    plg_setting.save()
diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
index 733e443638..6291e2005f 100644
--- a/InvenTree/plugin/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugin/samples/integration/test_samples_integration.py
@@ -3,6 +3,10 @@
 from django.test import TestCase
 from django.contrib.auth import get_user_model
 
+from InvenTree.helpers import setup_plugin
+
+from plugin.samples.integration.sample import SampleIntegrationPlugin
+
 
 class SampleIntegrationPluginTests(TestCase):
     """ Tests for SampleIntegrationPlugin """
@@ -13,6 +17,7 @@ class SampleIntegrationPluginTests(TestCase):
         user.objects.create_user('testuser', 'test@testing.com', 'password')
 
         self.client.login(username='testuser', password='password')
+        setup_plugin(SampleIntegrationPlugin.PLUGIN_SLUG, SampleIntegrationPlugin.PLUGIN_NAME)
 
     def test_view(self):
         """check the function of the custom  sample plugin """
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 3befdf6b00..54c8bd99e9 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -7,7 +7,9 @@ from django.contrib.auth import get_user_model
 
 from datetime import datetime
 
+from InvenTree.helpers import setup_plugin
 from plugin.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
+from plugin.samples.integration.sample import SampleIntegrationPlugin
 
 
 class BaseMixinDefinition:
@@ -110,6 +112,8 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
             pass
         self.mixin = TestCls()
 
+        setup_plugin(SampleIntegrationPlugin.PLUGIN_SLUG, SampleIntegrationPlugin.PLUGIN_NAME)
+
     def test_function(self):
         # test that this plugin is in settings
         self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)

From 8c82d2f900157ee4b47f3995404342b8b1370c22 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 00:02:47 +0100
Subject: [PATCH 264/493] fix test

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 4257fbeb64..e270b38485 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -42,7 +42,7 @@ class PluginIntegrationTests(TestCase):
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertEqual(plugin_names_integration, ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
+        self.assertEqual(set(plugin_names_integration), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 

From 7e478c332ab23d6afb9d0202fe5e521f299f6508 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 00:37:18 +0100
Subject: [PATCH 265/493] enable multiple admin buttons on one page

---
 InvenTree/InvenTree/static/script/inventree/inventree.js | 2 +-
 InvenTree/templates/admin_button.html                    | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js
index 85ae042728..078cb52924 100644
--- a/InvenTree/InvenTree/static/script/inventree/inventree.js
+++ b/InvenTree/InvenTree/static/script/inventree/inventree.js
@@ -208,7 +208,7 @@ function inventreeDocReady() {
     });
 
     // Callback for "admin view" button
-    $('#admin-button').click(function() {
+    $('#admin-button, .admin-button').click(function() {
         var url = $(this).attr('url');
 
         location.href = url;
diff --git a/InvenTree/templates/admin_button.html b/InvenTree/templates/admin_button.html
index 7186246e4a..ebe767f9ac 100644
--- a/InvenTree/templates/admin_button.html
+++ b/InvenTree/templates/admin_button.html
@@ -1,4 +1,4 @@
 {% load i18n %}
-<button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary' url='{{ url }}'>
+<button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary admin-button' url='{{ url }}'>
     <span class='fas fa-user-shield'></span>
 </button>
\ No newline at end of file

From ab2d758a3859743cec0eea6974a2ba2cc4ced91c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 00:37:36 +0100
Subject: [PATCH 266/493] save db reference

---
 InvenTree/plugin/apps.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 3d98a255d3..436fdc738e 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -76,6 +76,7 @@ class PluginAppConfig(AppConfig):
                 plugin = plugin()
                 logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged
+                plugin.pk = plugin_db_setting.pk
 
                 # safe reference
                 settings.INTEGRATION_PLUGINS[plugin.slug] = plugin

From ea277c2ad61e66649a93390cfa5c6baaeaf9d0ed Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 00:45:10 +0100
Subject: [PATCH 267/493] fix url

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index adfb349251..fae029b687 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -26,7 +26,7 @@
 </table>
 
 <h4>{% trans "Plugin list" %}
-    {% url 'admin:plugin_pluginconfig' as url %}
+    {% url 'admin:plugin_pluginconfig_changelist' as url %}
     {% include "admin_button.html" with url=url %}
 </h4>
 

From 6d47364e0604ddb3d0129cc16030e6db4fbef253 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 00:52:10 +0100
Subject: [PATCH 268/493] fix list equal

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index e270b38485..6018f9f0c6 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -42,7 +42,7 @@ class PluginIntegrationTests(TestCase):
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertEqual(set(plugin_names_integration), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
+        self.assertListEqual(set(plugin_names_integration), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 

From 6015de1cd90806843db57398cb9984a893233466 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:10:00 +0100
Subject: [PATCH 269/493] fix assertion inp

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 6018f9f0c6..bbdab1589d 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -42,7 +42,7 @@ class PluginIntegrationTests(TestCase):
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertListEqual(set(plugin_names_integration), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
+        self.assertListEqual(list(set(plugin_names_integration)), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 

From 5272b56d04e54731ee53aabc40f1df53603be585 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:15:20 +0100
Subject: [PATCH 270/493] activate plugins if testing

---
 InvenTree/plugin/apps.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 436fdc738e..8d43d09354 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -68,7 +68,8 @@ class PluginAppConfig(AppConfig):
             plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
             plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
 
-            if plugin_db_setting.active:
+            # always activate if testing
+            if settings.TESTING or plugin_db_setting.active:
                 # init package
                 # now we can be sure that an admin has activated the plugin -> 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.

From 367c37bbafa727b0107d8e93e1f7e907c6750fae Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:25:41 +0100
Subject: [PATCH 271/493] remove setup helper as it is not needed anymore

---
 .../plugin/samples/integration/test_samples_integration.py   | 5 -----
 InvenTree/plugin/test_integration.py                         | 4 ----
 2 files changed, 9 deletions(-)

diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
index 6291e2005f..733e443638 100644
--- a/InvenTree/plugin/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugin/samples/integration/test_samples_integration.py
@@ -3,10 +3,6 @@
 from django.test import TestCase
 from django.contrib.auth import get_user_model
 
-from InvenTree.helpers import setup_plugin
-
-from plugin.samples.integration.sample import SampleIntegrationPlugin
-
 
 class SampleIntegrationPluginTests(TestCase):
     """ Tests for SampleIntegrationPlugin """
@@ -17,7 +13,6 @@ class SampleIntegrationPluginTests(TestCase):
         user.objects.create_user('testuser', 'test@testing.com', 'password')
 
         self.client.login(username='testuser', password='password')
-        setup_plugin(SampleIntegrationPlugin.PLUGIN_SLUG, SampleIntegrationPlugin.PLUGIN_NAME)
 
     def test_view(self):
         """check the function of the custom  sample plugin """
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 54c8bd99e9..3befdf6b00 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -7,9 +7,7 @@ from django.contrib.auth import get_user_model
 
 from datetime import datetime
 
-from InvenTree.helpers import setup_plugin
 from plugin.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
-from plugin.samples.integration.sample import SampleIntegrationPlugin
 
 
 class BaseMixinDefinition:
@@ -112,8 +110,6 @@ class AppMixinTest(BaseMixinDefinition, TestCase):
             pass
         self.mixin = TestCls()
 
-        setup_plugin(SampleIntegrationPlugin.PLUGIN_SLUG, SampleIntegrationPlugin.PLUGIN_NAME)
-
     def test_function(self):
         # test that this plugin is in settings
         self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)

From 28af5dc128d87fb93f4f2aad4470ab7bb610ff5f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:26:26 +0100
Subject: [PATCH 272/493] add regions for easier code nav

---
 InvenTree/plugin/integration.py | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index da3c0ed18b..ae0d71f0d4 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -289,7 +289,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     def _is_package(self):
         return getattr(self, 'is_package', False)
 
-    # properties
+    # region properties
     @property
     def slug(self):
         """slug for the plugin"""
@@ -353,6 +353,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         """returns license of plugin"""
         license = getattr(self, 'LICENSE', None)
         return license
+    # endregion
 
     @property
     def package_path(self):
@@ -361,7 +362,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             return self.__module__
         return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
 
-    # mixins
+    # region mixins
     def mixin(self, key):
         """check if mixin is registered"""
         return key in self._mixins
@@ -372,8 +373,9 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             fnc_name = self._mixins.get(key)
             return getattr(self, fnc_name, True)
         return False
+    # endregion
 
-    # package info
+    # region package info
     def get_package_commit(self):
         """get last git commit for plugin"""
         return get_git_log(self.def_path)
@@ -402,3 +404,4 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
         # set variables
         self.package = package
         self.sign_state = sign_state
+    # endregion

From 9d3aab58d7286ac440e17262ea4f43d0b79a3a35 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:29:17 +0100
Subject: [PATCH 273/493] fix loading dir

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index bbdab1589d..09d2f97462 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -42,7 +42,7 @@ class PluginIntegrationTests(TestCase):
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertListEqual(list(set(plugin_names_integration)), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
+        self.assertListEqual(list(set(plugin_names_integration)), ['WrongIntegrationPlugin', 'NoIntegrationPlugin', 'SampleIntegrationPlugin'])
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 

From 860c56e4ca9e9a442bc0dd279bf1c2867969d66a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:36:31 +0100
Subject: [PATCH 274/493] remove helper

---
 InvenTree/InvenTree/helpers.py | 9 ---------
 1 file changed, 9 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 253f008715..65100d258e 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -712,12 +712,3 @@ def inheritors(cls):
                 subcls.add(child)
                 work.append(child)
     return subcls
-
-
-def setup_plugin(plg_key, plg_name):
-    """
-    Enables plugins by reference
-    """
-    plg_setting, _ = PluginConfig.objects.get_or_create(key=plg_key, name=plg_name)
-    plg_setting.active = True
-    plg_setting.save()

From 2638ef046dffc57622401a53fa2139649c839784 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:39:22 +0100
Subject: [PATCH 275/493] own flag to enable plugin testing

---
 InvenTree/InvenTree/settings.py                | 1 +
 InvenTree/InvenTree/urls.py                    | 2 +-
 InvenTree/plugin/apps.py                       | 6 +++---
 InvenTree/plugin/templatetags/plugin_extras.py | 2 +-
 4 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index a2b1de5527..6c6008a029 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -55,6 +55,7 @@ def get_setting(environment_var, backup_val, default_value=None):
 
 # Determine if we are running in "test" mode e.g. "manage.py test"
 TESTING = 'test' in sys.argv
+PLUGIN_TESTING = TESTING  # used to forece enable everything plugin
 
 # New requirement for django 3.2+
 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 0f7f555073..0a758e5a32 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -125,7 +125,7 @@ translated_javascript_urls = [
 # Integration plugin urls
 interation_urls = []
 try:
-    if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
+    if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
         for plugin in settings.INTEGRATION_PLUGINS.values():
             if plugin.mixin_enabled('urls'):
                 interation_urls.append(plugin.urlpatterns)
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 8d43d09354..932ed22027 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -69,7 +69,7 @@ class PluginAppConfig(AppConfig):
             plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
 
             # always activate if testing
-            if settings.TESTING or plugin_db_setting.active:
+            if settings.PLUGIN_TESTING or plugin_db_setting.active:
                 # init package
                 # now we can be sure that an admin has activated the plugin -> 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.
@@ -100,7 +100,7 @@ class PluginAppConfig(AppConfig):
     def activate_integration_settings(self, plugins):
         from common.models import InvenTreeSetting
 
-        if settings.TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
+        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
             logger.info('Registering IntegrationPlugin settings')
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('settings'):
@@ -113,7 +113,7 @@ class PluginAppConfig(AppConfig):
     def activate_integration_app(self, plugins):
         from common.models import InvenTreeSetting
 
-        if settings.TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+        if settings.PLUGIN_TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
             logger.info('Registering IntegrationPlugin apps')
             settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
             apps_changed = False
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 56d50fab01..44a1c292ed 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -39,7 +39,7 @@ def mixin_enabled(plugin, key, *args, **kwargs):
 @register.simple_tag()
 def navigation_enabled(*args, **kwargs):
     """Return if plugin navigation is enabled"""
-    if djangosettings.TESTING:
+    if djangosettings.PLUGIN_TESTING:
         return True
     return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION')
 

From c850269bd7aadb9158e6e31b62ec8180b3f20aaf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:39:41 +0100
Subject: [PATCH 276/493] log testing state

---
 .../plugin/samples/integration/test_samples_integration.py      | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
index 733e443638..c5e3577775 100644
--- a/InvenTree/plugin/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugin/samples/integration/test_samples_integration.py
@@ -2,6 +2,7 @@
 
 from django.test import TestCase
 from django.contrib.auth import get_user_model
+from django.conf import settings
 
 
 class SampleIntegrationPluginTests(TestCase):
@@ -16,6 +17,7 @@ class SampleIntegrationPluginTests(TestCase):
 
     def test_view(self):
         """check the function of the custom  sample plugin """
+        print(f'current testing settings: {settings.PLUGIN_TESTING}')
         response = self.client.get('/plugin/sample/ho/he/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b'Hi there testuser this works')

From 357f63180fefde66cd0db2be41b6a926e10c3c4f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:40:05 +0100
Subject: [PATCH 277/493] add settings url

---
 InvenTree/plugin/integration.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index ae0d71f0d4..6b142e3b24 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -9,6 +9,7 @@ from datetime import datetime
 import pathlib
 
 from django.conf.urls import url, include
+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 _
@@ -362,6 +363,11 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
             return self.__module__
         return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
 
+    @property
+    def settings_url(self):
+        """returns url to the settings panel"""
+        return f'{reverse("settings")}#select-plugin-{self.slug}'
+
     # region mixins
     def mixin(self, key):
         """check if mixin is registered"""

From cebd729facc9e48be04ebd9939cef42617826656 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 01:43:07 +0100
Subject: [PATCH 278/493] PEP fix

---
 InvenTree/InvenTree/helpers.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 65100d258e..6ae31d6f5f 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -22,7 +22,6 @@ import InvenTree.version
 from common.models import InvenTreeSetting
 from .settings import MEDIA_URL, STATIC_URL
 from common.settings import currency_code_default
-from plugin.models import PluginConfig
 
 from djmoney.money import Money
 

From ff3d9e373c9a78b0a584f347bcf449591e7613a6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 02:00:59 +0100
Subject: [PATCH 279/493] change order back

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 09d2f97462..bbdab1589d 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -42,7 +42,7 @@ class PluginIntegrationTests(TestCase):
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertListEqual(list(set(plugin_names_integration)), ['WrongIntegrationPlugin', 'NoIntegrationPlugin', 'SampleIntegrationPlugin'])
+        self.assertListEqual(list(set(plugin_names_integration)), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 

From b79f0052a4879bdac3c5c9a95664cd0c57dfdd9c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 02:02:01 +0100
Subject: [PATCH 280/493] assert that plugin testing is enabled

---
 InvenTree/plugin/samples/integration/test_samples_integration.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
index c5e3577775..df73fe8f93 100644
--- a/InvenTree/plugin/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugin/samples/integration/test_samples_integration.py
@@ -18,6 +18,7 @@ class SampleIntegrationPluginTests(TestCase):
     def test_view(self):
         """check the function of the custom  sample plugin """
         print(f'current testing settings: {settings.PLUGIN_TESTING}')
+        self.assertTrue(settings.PLUGIN_TESTING)
         response = self.client.get('/plugin/sample/ho/he/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b'Hi there testuser this works')

From 11c3ac8bf83fba222f0ea6f92337b59dbe81cbab Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 13 Nov 2021 23:40:14 +0100
Subject: [PATCH 281/493] make id fields in plugins read_only Fixes #2305

---
 InvenTree/plugin/admin.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 69ac29a679..f877cbebf7 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -6,4 +6,9 @@ from django.contrib import admin
 import plugin.models as models
 
 
-admin.site.register(models.PluginConfig, admin.ModelAdmin)
+class PluginConfigAdmin(admin.ModelAdmin):
+    """Custom admin with restricted id fields"""
+    readonly_fields = ["key", "name", ]
+
+
+admin.site.register(models.PluginConfig, PluginConfigAdmin)

From b10492f088b8432d40457665e4d36aaedf94f2b1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 00:24:32 +0100
Subject: [PATCH 282/493] rename global settings objects

---
 InvenTree/InvenTree/settings.py               |  2 +-
 InvenTree/common/models.py                    |  6 +--
 InvenTree/plugin/apps.py                      | 14 +++----
 InvenTree/plugin/integration.py               | 40 +++++++++----------
 .../plugin/samples/integration/sample.py      |  4 +-
 .../plugin/templatetags/plugin_extras.py      |  6 +--
 InvenTree/plugin/test_integration.py          | 32 +++++++--------
 InvenTree/plugin/test_plugin.py               |  4 +-
 .../InvenTree/settings/mixins/settings.html   |  2 +-
 .../templates/InvenTree/settings/plugin.html  |  2 +-
 .../InvenTree/settings/plugin_settings.html   |  4 +-
 11 files changed, 58 insertions(+), 58 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 6c6008a029..82ad722e2e 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -863,5 +863,5 @@ if DEBUG or TESTING:
 PLUGINS = []
 INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGINS_INACTIVE = {}
-INTEGRATION_PLUGIN_SETTING = {}
+INTEGRATION_PLUGIN_GLOBALSETTING = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 762589a1b0..50b7beac13 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -979,9 +979,9 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'validator': bool,
             'requires_restart': True,
         },
-        'ENABLE_PLUGINS_SETTING': {
-            'name': _('Enable setting integration'),
-            'description': _('Enable plugins to integrate into inventree settings'),
+        'ENABLE_PLUGINS_GLOBALSETTING': {
+            'name': _('Enable global setting integration'),
+            'description': _('Enable plugins to integrate into inventree global settings'),
             'default': False,
             'validator': bool,
             'requires_restart': True,
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 932ed22027..5ba9719a41 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -92,20 +92,20 @@ class PluginAppConfig(AppConfig):
         logger.info(f'Found {len(plugins)} active plugins')
 
         # if plugin settings are enabled enhance the settings
-        self.activate_integration_settings(plugins)
+        self.activate_integration_globalsettings(plugins)
 
         # if plugin apps are enabled
         self.activate_integration_app(plugins)
 
-    def activate_integration_settings(self, plugins):
+    def activate_integration_globalsettings(self, plugins):
         from common.models import InvenTreeSetting
 
-        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SETTING'):
-            logger.info('Registering IntegrationPlugin settings')
+        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
+            logger.info('Registering IntegrationPlugin global settings')
             for slug, plugin in plugins:
-                if plugin.mixin_enabled('settings'):
-                    plugin_setting = plugin.settingspatterns
-                    settings.INTEGRATION_PLUGIN_SETTING[slug] = plugin_setting
+                if plugin.mixin_enabled('globalsettings'):
+                    plugin_setting = plugin.globalsettingspatterns
+                    settings.INTEGRATION_PLUGIN_GLOBALSETTING[slug] = plugin_setting
 
                     # Add to settings dir
                     InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 6b142e3b24..c734725a42 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -57,53 +57,53 @@ class MixinBase:
         return mixins
 
 
-class SettingsMixin:
-    """Mixin that enables settings for the plugin"""
+class GlobalSettingsMixin:
+    """Mixin that enables global settings for the plugin"""
     class Meta:
         """meta options for this mixin"""
-        MIXIN_NAME = 'Settings'
+        MIXIN_NAME = 'Global settings'
 
     def __init__(self):
         super().__init__()
-        self.add_mixin('settings', 'has_settings', __class__)
-        self.settings = self.setup_settings()
+        self.add_mixin('globalsettings', 'has_globalsettings', __class__)
+        self.globalsettings = self.setup_globalsettings()
 
-    def setup_settings(self):
+    def setup_globalsettings(self):
         """
-        setup settings for this plugin
+        setup global settings for this plugin
         """
-        return getattr(self, 'SETTINGS', None)
+        return getattr(self, 'GLOBALSETTINGS', None)
 
     @property
-    def has_settings(self):
+    def has_globalsettings(self):
         """
-        does this plugin use custom settings
+        does this plugin use custom global settings
         """
-        return bool(self.settings)
+        return bool(self.globalsettings)
 
     @property
-    def settingspatterns(self):
+    def globalsettingspatterns(self):
         """
         get patterns for InvenTreeSetting defintion
         """
-        if self.has_settings:
-            return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.settings.items()}
+        if self.has_globalsettings:
+            return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
         return None
 
-    def _setting_name(self, key):
+    def _globalsetting_name(self, key):
         """get global name of setting"""
         return f'PLUGIN_{self.slug.upper()}_{key}'
 
-    def get_setting(self, key):
+    def get_globalsetting(self, key):
         """
-        get plugin setting by key
+        get plugin global setting by key
         """
         from common.models import InvenTreeSetting
-        return InvenTreeSetting.get_setting(self._setting_name(key))
+        return InvenTreeSetting.get_setting(self._globalsetting_name(key))
 
-    def set_setting(self, key, value, user):
+    def set_globalsetting(self, key, value, user):
         """
-        set plugin setting by key
+        set plugin global setting by key
         """
         from common.models import InvenTreeSetting
         return InvenTreeSetting.set_setting(self._setting_name(key), value, user)
diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py
index b6be51823d..bf99697b11 100644
--- a/InvenTree/plugin/samples/integration/sample.py
+++ b/InvenTree/plugin/samples/integration/sample.py
@@ -1,12 +1,12 @@
 """sample implementations for IntegrationPlugin"""
-from plugin.integration import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
+from plugin.integration import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
 from django.conf.urls import url, include
 
 
-class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
+class SampleIntegrationPlugin(AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase):
     """
     An full integration plugin
     """
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 44a1c292ed..05ba26d09d 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -25,9 +25,9 @@ def inactive_plugin_list(*args, **kwargs):
 
 
 @register.simple_tag()
-def plugin_settings(plugin, *args, **kwargs):
-    """ Return a list of all settings for a plugin """
-    return djangosettings.INTEGRATION_PLUGIN_SETTING.get(plugin)
+def plugin_globalsettings(plugin, *args, **kwargs):
+    """ Return a list of all global settings for a plugin """
+    return djangosettings.INTEGRATION_PLUGIN_GLOBALSETTING.get(plugin)
 
 
 @register.simple_tag()
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 3befdf6b00..9bc7c8639c 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -7,7 +7,7 @@ from django.contrib.auth import get_user_model
 
 from datetime import datetime
 
-from plugin.integration import AppMixin, IntegrationPluginBase, SettingsMixin, UrlsMixin, NavigationMixin
+from plugin.integration import AppMixin, IntegrationPluginBase, GlobalSettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:
@@ -18,19 +18,19 @@ class BaseMixinDefinition:
         self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
 
 
-class SettingsMixinTest(BaseMixinDefinition, TestCase):
-    MIXIN_HUMAN_NAME = 'Settings'
-    MIXIN_NAME = 'settings'
-    MIXIN_ENABLE_CHECK = 'has_settings'
+class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase):
+    MIXIN_HUMAN_NAME = 'Global settings'
+    MIXIN_NAME = 'globalsettings'
+    MIXIN_ENABLE_CHECK = 'has_globalsettings'
 
     TEST_SETTINGS = {'SETTING1': {'default': '123', }}
 
     def setUp(self):
-        class SettingsCls(SettingsMixin, IntegrationPluginBase):
+        class SettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
             SETTINGS = self.TEST_SETTINGS
         self.mixin = SettingsCls()
 
-        class NoSettingsCls(SettingsMixin, IntegrationPluginBase):
+        class NoSettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
             pass
         self.mixin_nothing = NoSettingsCls()
 
@@ -40,25 +40,25 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
 
     def test_function(self):
         # settings variable
-        self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
+        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.settings.items()}
-        self.assertEqual(self.mixin.settingspatterns, target_pattern)
+        self.assertEqual(self.mixin.globalsettingspatterns, target_pattern)
 
         # no settings
-        self.assertIsNone(self.mixin_nothing.settings)
-        self.assertIsNone(self.mixin_nothing.settingspatterns)
+        self.assertIsNone(self.mixin_nothing.globalsettings)
+        self.assertIsNone(self.mixin_nothing.globalsettingspatterns)
 
         # calling settings
         # not existing
-        self.assertEqual(self.mixin.get_setting('ABCD'), '')
-        self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
+        self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
+        self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
         # right setting
-        self.mixin.set_setting('SETTING1', '12345', self.test_user)
-        self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
+        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_setting(''), '')
+        self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
 
 
 class UrlsMixinTest(BaseMixinDefinition, TestCase):
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index bbdab1589d..f365944098 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -59,9 +59,9 @@ class PluginTagTests(TestCase):
         """test that all plugins are listed"""
         self.assertEqual(plugin_tags.plugin_list(), settings.INTEGRATION_PLUGINS)
 
-    def test_tag_plugin_settings(self):
+    def test_tag_plugin_globalsettings(self):
         """check all plugins are listed"""
-        self.assertEqual(plugin_tags.plugin_settings(self.sample), settings.INTEGRATION_PLUGIN_SETTING.get(self.sample))
+        self.assertEqual(plugin_tags.plugin_globalsettings(self.sample), settings.INTEGRATION_PLUGIN_GLOBALSETTING.get(self.sample))
 
     def test_tag_mixin_enabled(self):
         """check that mixin enabled functions work"""
diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html
index 3b5b1e80e3..3c87da8678 100644
--- a/InvenTree/templates/InvenTree/settings/mixins/settings.html
+++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html
@@ -2,7 +2,7 @@
 {% load plugin_extras %}
 
 <h4>{% trans "Settings" %}</h4>
-{% plugin_settings plugin_key as plugin_settings %}
+{% plugin_globalsettings plugin_key as plugin_settings %}
 
 <table class='table table-striped table-condensed'>
     <tbody>
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index fae029b687..d996933007 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -20,7 +20,7 @@
     <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_SETTING"%}
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%}
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
     </tbody>
 </table>
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 22e81e1873..3ae22371c2 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -120,8 +120,8 @@
     </div>
 </div>
 
-{% mixin_enabled plugin 'settings' as settings %}
-{% if settings %}
+{% mixin_enabled plugin 'globalsettings' as globalsettings %}
+{% if globalsettings %}
     {% include 'InvenTree/settings/mixins/settings.html' %}
 {% endif %}
 

From 1391df723605a6e35f321bafd7c69510907f0b5e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 00:49:00 +0100
Subject: [PATCH 283/493] fix test for global settings

---
 InvenTree/plugin/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 9bc7c8639c..ca8cd7d81a 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -27,7 +27,7 @@ class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase):
 
     def setUp(self):
         class SettingsCls(GlobalSettingsMixin, IntegrationPluginBase):
-            SETTINGS = self.TEST_SETTINGS
+            GLOBALSETTINGS = self.TEST_SETTINGS
         self.mixin = SettingsCls()
 
         class NoSettingsCls(GlobalSettingsMixin, IntegrationPluginBase):

From 013e8ab3bd840d058a12b8c818452caf2fbeda26 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 01:12:23 +0100
Subject: [PATCH 284/493] disable IntegrationPlugin loading from setup hook in
 testing

---
 InvenTree/InvenTree/settings.py |  5 ++++-
 InvenTree/plugin/apps.py        | 11 ++++++-----
 2 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 40a8082f25..7db3d85628 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -55,7 +55,6 @@ def get_setting(environment_var, backup_val, default_value=None):
 
 # Determine if we are running in "test" mode e.g. "manage.py test"
 TESTING = 'test' in sys.argv
-PLUGIN_TESTING = TESTING  # used to forece enable everything plugin
 
 # New requirement for django 3.2+
 DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
@@ -866,3 +865,7 @@ INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGINS_INACTIVE = {}
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
+
+# Test settings
+PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
+PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 5ba9719a41..d11e9034f8 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -42,11 +42,12 @@ class PluginAppConfig(AppConfig):
             if modules:
                 [settings.PLUGINS.append(item) for item in modules]
 
-        # Collect plugins from setup entry points
-        for entry in metadata.entry_points().get('inventree_plugins', []):
-            plugin = entry.load()
-            plugin.is_package = True
-            settings.PLUGINS.append(plugin)
+        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', []):
+                plugin = entry.load()
+                plugin.is_package = True
+                settings.PLUGINS.append(plugin)
 
         # Log found plugins
         logger.info(f'Found {len(settings.PLUGINS)} plugins!')

From 990ad95c13e5f754c6d0548e1a31b289b17f1961 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 01:32:44 +0100
Subject: [PATCH 285/493] fix global settings test

---
 InvenTree/plugin/test_integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index ca8cd7d81a..0391c3d35d 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -43,7 +43,7 @@ class GlobalSettingsMixinTest(BaseMixinDefinition, TestCase):
         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.settings.items()}
+        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

From 4ac589582290ac587cf0a9e071291729b5187a29 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 01:33:07 +0100
Subject: [PATCH 286/493] compare ordered

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index f365944098..45d91d70d2 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -42,7 +42,7 @@ class PluginIntegrationTests(TestCase):
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertListEqual(list(set(plugin_names_integration)), ['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin'])
+        self.assertListEqual(sorted(list(set(plugin_names_integration))), sorted(['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin']))
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 

From 01cf848fbba91e31d4c139c102dd8d046635b705 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 01:53:35 +0100
Subject: [PATCH 287/493] fix wrong set settings

---
 InvenTree/plugin/integration.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index c734725a42..4d617c3c56 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -106,7 +106,7 @@ class GlobalSettingsMixin:
         set plugin global setting by key
         """
         from common.models import InvenTreeSetting
-        return InvenTreeSetting.set_setting(self._setting_name(key), value, user)
+        return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
 
 
 class UrlsMixin:

From 4abb23963ab5f496326116b852377a8dc1f41afa Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 01:59:47 +0100
Subject: [PATCH 288/493] log if db not loaded

---
 InvenTree/plugin/apps.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index d11e9034f8..b8fcc38579 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -32,7 +32,7 @@ class PluginAppConfig(AppConfig):
             self.activate_integration()
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
-            pass
+            logger.debug('Database was not ready for loading PluginAppConfig')
 
     def collect_plugins(self):
         """collect integration plugins from all possible ways of loading"""

From 8faed7227857e41e0e664fde0b1c112fa5f2cc68 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 02:03:56 +0100
Subject: [PATCH 289/493] make db setting fetching safe

---
 InvenTree/plugin/apps.py | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b8fcc38579..93d261516d 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -67,10 +67,15 @@ class PluginAppConfig(AppConfig):
             # 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
-            plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
+            try:
+                plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
+            except (OperationalError, ProgrammingError) as error:
+                # Exception if the database has not been migrated yet
+                logger.error('Database error while gettign/setting PluginConfig', error)
+                plugin_db_setting = None
 
             # always activate if testing
-            if settings.PLUGIN_TESTING or plugin_db_setting.active:
+            if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
                 # init package
                 # now we can be sure that an admin has activated the plugin -> 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.
@@ -78,7 +83,8 @@ class PluginAppConfig(AppConfig):
                 plugin = plugin()
                 logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged
-                plugin.pk = plugin_db_setting.pk
+                if plugin_db_setting:
+                    plugin.pk = plugin_db_setting.pk
 
                 # safe reference
                 settings.INTEGRATION_PLUGINS[plugin.slug] = plugin

From e3d334f467fc9cdf0d40288805af4010bac3af53 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 02:45:59 +0100
Subject: [PATCH 290/493] remove debug message

---
 InvenTree/plugin/apps.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 93d261516d..d30bbfa7d0 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -32,7 +32,7 @@ class PluginAppConfig(AppConfig):
             self.activate_integration()
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
-            logger.debug('Database was not ready for loading PluginAppConfig')
+            pass
 
     def collect_plugins(self):
         """collect integration plugins from all possible ways of loading"""
@@ -70,8 +70,9 @@ class PluginAppConfig(AppConfig):
             try:
                 plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
             except (OperationalError, ProgrammingError) as error:
-                # Exception if the database has not been migrated yet
-                logger.error('Database error while gettign/setting PluginConfig', error)
+                # Exception if the database has not been migrated yet - check if test are running - raise if not
+                if not settings.PLUGIN_TESTING:
+                    raise error
                 plugin_db_setting = None
 
             # always activate if testing

From c059583b08f8f7e7758e244a6e389a154f8be742 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 22:01:00 +0100
Subject: [PATCH 291/493] add live reloading

---
 InvenTree/InvenTree/settings.py |  4 +-
 InvenTree/plugin/apps.py        | 95 +++++++++++++++++++++++++++++----
 InvenTree/plugin/models.py      | 21 ++++++++
 3 files changed, 109 insertions(+), 11 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 7db3d85628..fb26da332f 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -864,7 +864,9 @@ PLUGINS = []
 INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGINS_INACTIVE = {}
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
-INTEGRATION_APPS_LOADED = False  # Marks if apps were reloaded yet
+
+INTEGRATION_APPS_LOADED = False     # Marks if apps were reloaded yet
+INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
 
 # Test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index d30bbfa7d0..ba19d3588a 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -8,6 +8,8 @@ from typing import OrderedDict
 from django.apps import AppConfig, apps
 from django.conf import settings
 from django.db.utils import OperationalError, ProgrammingError
+from django.conf.urls import url
+from django.contrib import admin
 
 try:
     from importlib import metadata
@@ -25,14 +27,36 @@ class PluginAppConfig(AppConfig):
 
     def ready(self):
         self.collect_plugins()
+        self.load_plugins()
 
+    def load_plugins(self):
+        logger.info('Start loading plugins')
         try:
-            # we are using the db from here - 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()
-            self.activate_integration()
+            self.activate_plugins()
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
-            pass
+            logger.info('Database not accessible while loading plugins')
+        logger.info('Finished loading plugins')
+
+    def unload_plugins(self):
+        logger.info('Start unloading plugins')
+        # remove all plugins from registry
+        # plugins = settings.INTEGRATION_PLUGINS
+        settings.INTEGRATION_PLUGINS = {}
+        # plugins_inactive = settings.INTEGRATION_PLUGINS_INACTIVE
+        settings.INTEGRATION_PLUGINS_INACTIVE = {}
+
+        # deactivate all integrations
+        self.deactivate_plugins()
+        logger.info('Finished unloading plugins')
+
+    def reload_plugins(self):
+        logger.info('Start reloading plugins')
+        self.unload_plugins()
+        self.load_plugins()
+        logger.info('Finished reloading plugins')
 
     def collect_plugins(self):
         """collect integration plugins from all possible ways of loading"""
@@ -42,6 +66,7 @@ class PluginAppConfig(AppConfig):
             if modules:
                 [settings.PLUGINS.append(item) for item in modules]
 
+        # check if 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', []):
@@ -93,7 +118,7 @@ class PluginAppConfig(AppConfig):
                 # save for later reference
                 settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
 
-    def activate_integration(self):
+    def activate_plugins(self):
         """fullfill integrations for all activated plugins"""
         # activate integrations
         plugins = settings.INTEGRATION_PLUGINS.items()
@@ -105,6 +130,11 @@ class PluginAppConfig(AppConfig):
         # if plugin apps are enabled
         self.activate_integration_app(plugins)
 
+    def deactivate_plugins(self):
+        self.deactivate_integration_app()
+        self.deactivate_integration_globalsettings()
+
+    # region integration_globalsettings
     def activate_integration_globalsettings(self, plugins):
         from common.models import InvenTreeSetting
 
@@ -118,6 +148,20 @@ class PluginAppConfig(AppConfig):
                     # Add to settings dir
                     InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
 
+    def deactivate_integration_globalsettings(self):
+        from common.models import InvenTreeSetting
+
+        # collect all settings
+        plugin_settings = {}
+        for _, plugin_setting in settings.INTEGRATION_PLUGIN_GLOBALSETTING.items():
+            plugin_settings.update(plugin_setting)
+
+        # remove settings
+        for setting in plugin_settings:
+            InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
+    # endregion
+
+    # region integration_app
     def activate_integration_app(self, plugins):
         from common.models import InvenTreeSetting
 
@@ -137,12 +181,43 @@ class PluginAppConfig(AppConfig):
                         plugin_path = plugin.PLUGIN_NAME
                     if plugin_path not in settings.INSTALLED_APPS:
                         settings.INSTALLED_APPS += [plugin_path]
+                        settings.INTEGRATION_APPS_PATHS += [plugin_path]
                         apps_changed = True
 
-            # if apps were changed reload
-            # TODO this is a bit jankey to be honest
             if apps_changed:
-                apps.app_configs = OrderedDict()
-                apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
-                apps.clear_cache()
-                apps.populate(settings.INSTALLED_APPS)
+                # if apps were changed reload
+                self._reload_apps()
+                # update urls
+                self._update_urls()
+
+    def deactivate_integration_app(self):
+        # remove plugin from installed_apps
+        for plugin in settings.INTEGRATION_APPS_PATHS:
+            settings.INSTALLED_APPS.remove(plugin)
+
+        # reset load flag and reload apps
+        settings.INTEGRATION_APPS_PATHS = []
+        settings.INTEGRATION_APPS_LOADED = False
+        self._reload_apps()
+
+        # update urls
+        self._update_urls()
+
+    def _update_urls(self):
+        from InvenTree.urls import urlpatterns as root_urlpatterns
+        # add admin urls
+        new_conf = url(r'^admin/', admin.site.urls, name='inventree-admin')
+        for index, a in enumerate(root_urlpatterns):
+            if hasattr(a, 'app_name') and a.app_name == 'admin':
+                root_urlpatterns[index] = new_conf
+                print('exchanged')
+                break
+        print('done')
+
+    def _reload_apps(self):
+        # TODO this is a bit jankey to be honest
+        apps.app_configs = OrderedDict()
+        apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+        apps.clear_cache()
+        apps.populate(settings.INSTALLED_APPS)
+    # endregion
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 1749ea8cd8..06606fd5d6 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -7,6 +7,7 @@ from __future__ import unicode_literals
 
 from django.utils.translation import gettext_lazy as _
 from django.db import models
+from django.apps import apps
 
 
 class PluginConfig(models.Model):
@@ -46,3 +47,23 @@ class PluginConfig(models.Model):
         if not self.active:
             name += '(not active)'
         return name
+    
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.__org_active = self.active
+
+    def save(self, force_insert=False, force_update=False, *args, **kwargs):
+        """extend save method to reload plugins if the 'active' status changes"""
+        ret = super().save(force_insert, force_update,  *args, **kwargs)
+        app = apps.get_app_config('plugin')
+
+        if self.active is False and self.__org_active is True:
+            print('deactivated')
+            app.reload_plugins()
+
+        elif self.active is True and self.__org_active is False:
+            print('activated')
+            app.reload_plugins()
+
+        return ret
+

From d586d6225c1a1dbb9fd6fadc1840a36e3481e296 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 22:02:14 +0100
Subject: [PATCH 292/493] more struc

---
 InvenTree/plugin/apps.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index ba19d3588a..49248b590b 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -29,6 +29,7 @@ class PluginAppConfig(AppConfig):
         self.collect_plugins()
         self.load_plugins()
 
+    # region general public plugin functions
     def load_plugins(self):
         logger.info('Start loading plugins')
         try:
@@ -57,7 +58,9 @@ class PluginAppConfig(AppConfig):
         self.unload_plugins()
         self.load_plugins()
         logger.info('Finished reloading plugins')
+    # endregion
 
+    # region general mechanisms
     def collect_plugins(self):
         """collect integration plugins from all possible ways of loading"""
         # Collect plugins from paths
@@ -133,6 +136,7 @@ class PluginAppConfig(AppConfig):
     def deactivate_plugins(self):
         self.deactivate_integration_app()
         self.deactivate_integration_globalsettings()
+    # endregion
 
     # region integration_globalsettings
     def activate_integration_globalsettings(self, plugins):

From 6922e2423786034b69fb39ee21c8037b18c8d330 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 22:08:57 +0100
Subject: [PATCH 293/493] refactor and doc

---
 InvenTree/plugin/apps.py | 31 +++++++++++++++++--------------
 1 file changed, 17 insertions(+), 14 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 49248b590b..7f6ab1c980 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -26,22 +26,24 @@ class PluginAppConfig(AppConfig):
     name = 'plugin'
 
     def ready(self):
-        self.collect_plugins()
+        self._collect_plugins()
         self.load_plugins()
 
-    # region general public plugin functions
+    # region public plugin functions
     def load_plugins(self):
+        """load and activate all IntegrationPlugins"""
         logger.info('Start loading plugins')
         try:
             # we are using the db so for migrations etc we need to try this block
-            self.init_plugins()
-            self.activate_plugins()
+            self._init_plugins()
+            self._activate_plugins()
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
             logger.info('Database not accessible while loading plugins')
         logger.info('Finished loading plugins')
 
     def unload_plugins(self):
+        """unload and deactivate all IntegrationPlugins"""
         logger.info('Start unloading plugins')
         # remove all plugins from registry
         # plugins = settings.INTEGRATION_PLUGINS
@@ -50,18 +52,19 @@ class PluginAppConfig(AppConfig):
         settings.INTEGRATION_PLUGINS_INACTIVE = {}
 
         # deactivate all integrations
-        self.deactivate_plugins()
+        self._deactivate_plugins()
         logger.info('Finished unloading plugins')
 
     def reload_plugins(self):
+        """safely reload IntegrationPlugins"""
         logger.info('Start reloading plugins')
         self.unload_plugins()
         self.load_plugins()
         logger.info('Finished reloading plugins')
     # endregion
 
-    # region general mechanisms
-    def collect_plugins(self):
+    # region general plugin managment mechanisms
+    def _collect_plugins(self):
         """collect integration plugins from all possible ways of loading"""
         # Collect plugins from paths
         for plugin in settings.PLUGIN_DIRS:
@@ -81,7 +84,7 @@ class PluginAppConfig(AppConfig):
         logger.info(f'Found {len(settings.PLUGINS)} plugins!')
         logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
 
-    def init_plugins(self):
+    def _init_plugins(self):
         """initialise all found plugins"""
         from plugin.models import PluginConfig
 
@@ -121,23 +124,22 @@ class PluginAppConfig(AppConfig):
                 # save for later reference
                 settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
 
-    def activate_plugins(self):
-        """fullfill integrations for all activated plugins"""
+    def _activate_plugins(self):
+        """run integration functions for all plugins"""
         # activate integrations
         plugins = settings.INTEGRATION_PLUGINS.items()
         logger.info(f'Found {len(plugins)} active plugins')
 
-        # if plugin settings are enabled enhance the settings
         self.activate_integration_globalsettings(plugins)
-
-        # if plugin apps are enabled
         self.activate_integration_app(plugins)
 
-    def deactivate_plugins(self):
+    def _deactivate_plugins(self):
+        """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
@@ -225,3 +227,4 @@ class PluginAppConfig(AppConfig):
         apps.clear_cache()
         apps.populate(settings.INSTALLED_APPS)
     # endregion
+    # endregion

From fd5939d233a940da2a243e1440ae76eb131537f0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 22:14:50 +0100
Subject: [PATCH 294/493] simplify function

---
 InvenTree/plugin/apps.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 7f6ab1c980..3fd88021b4 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -211,11 +211,10 @@ class PluginAppConfig(AppConfig):
 
     def _update_urls(self):
         from InvenTree.urls import urlpatterns as root_urlpatterns
-        # add admin urls
-        new_conf = url(r'^admin/', admin.site.urls, name='inventree-admin')
+
         for index, a in enumerate(root_urlpatterns):
             if hasattr(a, 'app_name') and a.app_name == 'admin':
-                root_urlpatterns[index] = new_conf
+                root_urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
                 print('exchanged')
                 break
         print('done')

From f13507e23c8b97f84d853dca8b6e1446fb3cc664 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 22:16:45 +0100
Subject: [PATCH 295/493] refactor

---
 InvenTree/plugin/apps.py | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 3fd88021b4..533c2df108 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -179,12 +179,7 @@ class PluginAppConfig(AppConfig):
             # add them to the INSTALLED_APPS
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('app'):
-                    try:
-                        # for local path plugins
-                        plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-                    except ValueError:
-                        # plugin is shipped as package
-                        plugin_path = plugin.PLUGIN_NAME
+                    plugin_path = self._get_plugin_path(plugin)
                     if plugin_path not in settings.INSTALLED_APPS:
                         settings.INSTALLED_APPS += [plugin_path]
                         settings.INTEGRATION_APPS_PATHS += [plugin_path]
@@ -196,6 +191,15 @@ class PluginAppConfig(AppConfig):
                 # update urls
                 self._update_urls()
 
+    def _get_plugin_path(self, plugin):
+        try:
+                        # for local path plugins
+            plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+        except ValueError:
+                        # plugin is shipped as package
+            plugin_path = plugin.PLUGIN_NAME
+        return plugin_path
+
     def deactivate_integration_app(self):
         # remove plugin from installed_apps
         for plugin in settings.INTEGRATION_APPS_PATHS:

From 9ecf9603d6c29c3b89390f56b62a825d06af8eec Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 23:08:28 +0100
Subject: [PATCH 296/493] load django internal reloading mechanisms

---
 InvenTree/InvenTree/settings.py |  1 +
 InvenTree/plugin/apps.py        | 13 ++++++-------
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index fb26da332f..31a11e2b4c 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -866,6 +866,7 @@ INTEGRATION_PLUGINS_INACTIVE = {}
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
 INTEGRATION_APPS_LOADED = False     # Marks if apps were reloaded yet
+INTEGRATION_PLUGINS_RELOADING = False
 INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
 
 # Test settings
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 533c2df108..a73e7aec1a 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -26,8 +26,9 @@ class PluginAppConfig(AppConfig):
     name = 'plugin'
 
     def ready(self):
-        self._collect_plugins()
-        self.load_plugins()
+        if not settings.INTEGRATION_PLUGINS_RELOADING:
+            self._collect_plugins()
+            self.load_plugins()
 
     # region public plugin functions
     def load_plugins(self):
@@ -224,10 +225,8 @@ class PluginAppConfig(AppConfig):
         print('done')
 
     def _reload_apps(self):
-        # TODO this is a bit jankey to be honest
-        apps.app_configs = OrderedDict()
-        apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
-        apps.clear_cache()
-        apps.populate(settings.INSTALLED_APPS)
+        settings.INTEGRATION_PLUGINS_RELOADING = True
+        apps.set_installed_apps(settings.INSTALLED_APPS)
+        settings.INTEGRATION_PLUGINS_RELOADING = False
     # endregion
     # endregion

From eb02a851544df79d2ee1aa5fe8c5c3d8ac211afc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 23:09:42 +0100
Subject: [PATCH 297/493] fix indentation

---
 InvenTree/plugin/apps.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index a73e7aec1a..4a7d6c3add 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -194,10 +194,10 @@ class PluginAppConfig(AppConfig):
 
     def _get_plugin_path(self, plugin):
         try:
-                        # for local path plugins
+            # for local path plugins
             plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
         except ValueError:
-                        # plugin is shipped as package
+            # plugin is shipped as package
             plugin_path = plugin.PLUGIN_NAME
         return plugin_path
 

From 47bb9466b75a72652b1a4c28340be862534d536a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 14 Nov 2021 23:49:00 +0100
Subject: [PATCH 298/493] fix initial startup phase

---
 InvenTree/InvenTree/settings.py |  2 +-
 InvenTree/plugin/apps.py        | 14 +++++++++++---
 2 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 31a11e2b4c..b49dbfbe15 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -865,7 +865,7 @@ INTEGRATION_PLUGINS = {}
 INTEGRATION_PLUGINS_INACTIVE = {}
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
-INTEGRATION_APPS_LOADED = False     # Marks if apps were reloaded yet
+INTEGRATION_APPS_LOADING = True     # Marks if apps were reloaded yet
 INTEGRATION_PLUGINS_RELOADING = False
 INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 4a7d6c3add..f422a27744 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -172,9 +172,8 @@ class PluginAppConfig(AppConfig):
     def activate_integration_app(self, plugins):
         from common.models import InvenTreeSetting
 
-        if settings.PLUGIN_TESTING or ((not settings.INTEGRATION_APPS_LOADED) and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+        if settings.PLUGIN_TESTING or (settings.INTEGRATION_APPS_LOADING and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
             logger.info('Registering IntegrationPlugin apps')
-            settings.INTEGRATION_APPS_LOADED = True  # ensure this section will not run again
             apps_changed = False
 
             # add them to the INSTALLED_APPS
@@ -188,6 +187,9 @@ class PluginAppConfig(AppConfig):
 
             if apps_changed:
                 # if apps were changed reload
+                if settings.INTEGRATION_APPS_LOADING:
+                    settings.INTEGRATION_APPS_LOADING = False
+                    self._reload_apps(populate=True)
                 self._reload_apps()
                 # update urls
                 self._update_urls()
@@ -224,7 +226,13 @@ class PluginAppConfig(AppConfig):
                 break
         print('done')
 
-    def _reload_apps(self):
+    def _reload_apps(self, populate: bool = False):
+        if populate:
+            apps.app_configs = OrderedDict()
+            apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+            apps.clear_cache()
+            apps.populate(settings.INSTALLED_APPS)
+            return
         settings.INTEGRATION_PLUGINS_RELOADING = True
         apps.set_installed_apps(settings.INSTALLED_APPS)
         settings.INTEGRATION_PLUGINS_RELOADING = False

From 5b04f812a98071b816cede2a579fc63b2f4b820c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 00:04:56 +0100
Subject: [PATCH 299/493] refactor

---
 InvenTree/InvenTree/urls.py | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 0a758e5a32..1584e9f5ab 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -123,12 +123,17 @@ translated_javascript_urls = [
 ]
 
 # Integration plugin urls
-interation_urls = []
+integration_urls = []
+def get_integration_urls():
+    urls = []
+    for plugin in settings.INTEGRATION_PLUGINS.values():
+        if plugin.mixin_enabled('urls'):
+            urls.append(plugin.urlpatterns)
+    return urls
+
 try:
     if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
-        for plugin in settings.INTEGRATION_PLUGINS.values():
-            if plugin.mixin_enabled('urls'):
-                interation_urls.append(plugin.urlpatterns)
+        integration_urls = get_integration_urls()
 except (OperationalError, ProgrammingError):
     # Exception if the database has not been migrated yet
     pass
@@ -172,7 +177,7 @@ urlpatterns = [
     url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
 
     # plugins
-    url(f'^{settings.PLUGIN_URL}/', include((interation_urls, 'plugin'))),
+    url(f'^{settings.PLUGIN_URL}/', include((integration_urls, 'plugin'))),
 
     url(r'^markdownx/', include('markdownx.urls')),
 

From 84a675ae399f2d3c9723068ad778674eb0753b51 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 00:10:05 +0100
Subject: [PATCH 300/493] update urls too

---
 InvenTree/plugin/apps.py | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index f422a27744..b0a63e68bd 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -8,7 +8,7 @@ from typing import OrderedDict
 from django.apps import AppConfig, apps
 from django.conf import settings
 from django.db.utils import OperationalError, ProgrammingError
-from django.conf.urls import url
+from django.conf.urls import url, include
 from django.contrib import admin
 
 try:
@@ -217,13 +217,15 @@ class PluginAppConfig(AppConfig):
         self._update_urls()
 
     def _update_urls(self):
-        from InvenTree.urls import urlpatterns as root_urlpatterns
+        from InvenTree.urls import urlpatterns, get_integration_urls
 
-        for index, a in enumerate(root_urlpatterns):
-            if hasattr(a, 'app_name') and a.app_name == 'admin':
-                root_urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
-                print('exchanged')
-                break
+        for index, a in enumerate(urlpatterns):
+            if hasattr(a, 'app_name'):
+                if a.app_name == 'admin':
+                    urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
+                elif a.app_name == 'plugin':
+                    integ_urls = get_integration_urls()
+                    urlpatterns[index] = url(f'^{settings.PLUGIN_URL}/', include((integ_urls, 'plugin')))
         print('done')
 
     def _reload_apps(self, populate: bool = False):

From d2a34b83c6bc79c641e86cb879b30470db9504f6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 00:21:47 +0100
Subject: [PATCH 301/493] clear settings reliably

---
 InvenTree/plugin/apps.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b0a63e68bd..d10f41a808 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -166,6 +166,9 @@ class PluginAppConfig(AppConfig):
         # remove settings
         for setting in plugin_settings:
             InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
+
+        # clear cache
+        settings.INTEGRATION_PLUGIN_GLOBALSETTING = {}
     # endregion
 
     # region integration_app

From 65ff226b90617e554d2c36472e7e1dae2eab973a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 01:01:01 +0100
Subject: [PATCH 302/493] remove debug messages

---
 InvenTree/plugin/apps.py   | 1 -
 InvenTree/plugin/models.py | 2 --
 2 files changed, 3 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index d10f41a808..e60de35833 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -229,7 +229,6 @@ class PluginAppConfig(AppConfig):
                 elif a.app_name == 'plugin':
                     integ_urls = get_integration_urls()
                     urlpatterns[index] = url(f'^{settings.PLUGIN_URL}/', include((integ_urls, 'plugin')))
-        print('done')
 
     def _reload_apps(self, populate: bool = False):
         if populate:
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 06606fd5d6..4aebcd6082 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -58,11 +58,9 @@ class PluginConfig(models.Model):
         app = apps.get_app_config('plugin')
 
         if self.active is False and self.__org_active is True:
-            print('deactivated')
             app.reload_plugins()
 
         elif self.active is True and self.__org_active is False:
-            print('activated')
             app.reload_plugins()
 
         return ret

From 87edbf7c334497d985d3e5dcd3743fe48bac11cf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 01:01:55 +0100
Subject: [PATCH 303/493] unresgister models when deactivating

---
 InvenTree/plugin/apps.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index e60de35833..40c021dc1e 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -207,6 +207,14 @@ class PluginAppConfig(AppConfig):
         return plugin_path
 
     def deactivate_integration_app(self):
+        # unregister models from admin
+        for app_name in settings.INTEGRATION_APPS_PATHS:
+            for model in apps.get_app_config(app_name.split('.')[-1]).get_models():
+                try:
+                    admin.site.unregister(model)
+                except:
+                    pass
+
         # remove plugin from installed_apps
         for plugin in settings.INTEGRATION_APPS_PATHS:
             settings.INSTALLED_APPS.remove(plugin)

From 81335ee1d51db879fbad0815bd6d6db92e394394 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:05:57 +0100
Subject: [PATCH 304/493] clear url caches

---
 InvenTree/plugin/apps.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 40c021dc1e..d777d932c3 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -9,6 +9,7 @@ from django.apps import AppConfig, apps
 from django.conf import settings
 from django.db.utils import OperationalError, ProgrammingError
 from django.conf.urls import url, include
+from django.urls import clear_url_caches
 from django.contrib import admin
 
 try:
@@ -237,6 +238,7 @@ class PluginAppConfig(AppConfig):
                 elif a.app_name == 'plugin':
                     integ_urls = get_integration_urls()
                     urlpatterns[index] = url(f'^{settings.PLUGIN_URL}/', include((integ_urls, 'plugin')))
+        clear_url_caches()
 
     def _reload_apps(self, populate: bool = False):
         if populate:

From c41f16837d19fbd67067043bf237ea47ecd57c36 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:06:38 +0100
Subject: [PATCH 305/493] remove blocking condition

---
 InvenTree/plugin/apps.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index d777d932c3..a4b75afdd7 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -176,7 +176,7 @@ class PluginAppConfig(AppConfig):
     def activate_integration_app(self, plugins):
         from common.models import InvenTreeSetting
 
-        if settings.PLUGIN_TESTING or (settings.INTEGRATION_APPS_LOADING and InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+        if settings.PLUGIN_TESTING or (InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
             logger.info('Registering IntegrationPlugin apps')
             apps_changed = False
 

From 1aafec7107866f19da04a2d773b67528b2cfa2cc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:10:43 +0100
Subject: [PATCH 306/493] PEP fixes

---
 InvenTree/plugin/models.py  | 5 ++---
 InvenTree/plugin/plugins.py | 2 +-
 2 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 4aebcd6082..73bd09dc3a 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -47,14 +47,14 @@ class PluginConfig(models.Model):
         if not self.active:
             name += '(not active)'
         return name
-    
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.__org_active = self.active
 
     def save(self, force_insert=False, force_update=False, *args, **kwargs):
         """extend save method to reload plugins if the 'active' status changes"""
-        ret = super().save(force_insert, force_update,  *args, **kwargs)
+        ret = super().save(force_insert, force_update, *args, **kwargs)
         app = apps.get_app_config('plugin')
 
         if self.active is False and self.__org_active is True:
@@ -64,4 +64,3 @@ class PluginConfig(models.Model):
             app.reload_plugins()
 
         return ret
-
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index d29b8093da..0822e412f3 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -29,7 +29,7 @@ def get_modules(pkg, recursive: bool = False):
     """get all modules in a package"""
     if not recursive:
         return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
-    
+
     context = {}
     for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
         try:

From dbfe0d39eae0796cdc23f517e796e965a6291dfe Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:10:57 +0100
Subject: [PATCH 307/493] this is simpler to read

---
 InvenTree/plugin/apps.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index a4b75afdd7..430dc3e125 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -176,7 +176,7 @@ class PluginAppConfig(AppConfig):
     def activate_integration_app(self, plugins):
         from common.models import InvenTreeSetting
 
-        if settings.PLUGIN_TESTING or (InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP')):
+        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
             logger.info('Registering IntegrationPlugin apps')
             apps_changed = False
 

From 84ea56a8f252eaf29cb8f7a71bd0285d7df8dbd9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:11:12 +0100
Subject: [PATCH 308/493] docstrings should be manadtory

---
 InvenTree/plugin/models.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 73bd09dc3a..c29e532789 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -49,6 +49,7 @@ class PluginConfig(models.Model):
         return name
 
     def __init__(self, *args, **kwargs):
+        """override to set original state of"""
         super().__init__(*args, **kwargs)
         self.__org_active = self.active
 

From 35e211e33052fe302210c44a411e56e6eee88816 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:11:28 +0100
Subject: [PATCH 309/493] this was for finding a testing error

---
 .../plugin/samples/integration/test_samples_integration.py      | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
index df73fe8f93..8e2ef41634 100644
--- a/InvenTree/plugin/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugin/samples/integration/test_samples_integration.py
@@ -17,8 +17,6 @@ class SampleIntegrationPluginTests(TestCase):
 
     def test_view(self):
         """check the function of the custom  sample plugin """
-        print(f'current testing settings: {settings.PLUGIN_TESTING}')
-        self.assertTrue(settings.PLUGIN_TESTING)
         response = self.client.get('/plugin/sample/ho/he/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.content, b'Hi there testuser this works')

From 7129a359009b79b473cbad5bb895a5b7b9f37e6a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 02:20:37 +0100
Subject: [PATCH 310/493] add todo regarding reload safety

---
 InvenTree/plugin/apps.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 430dc3e125..89ce89ae88 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -59,6 +59,7 @@ class PluginAppConfig(AppConfig):
 
     def reload_plugins(self):
         """safely reload IntegrationPlugins"""
+        # TODO check if the system is in maintainance mode before reloading
         logger.info('Start reloading plugins')
         self.unload_plugins()
         self.load_plugins()

From 45167fe2f064712ce5f5ad9947d3458b41eb0a86 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 07:47:52 +0100
Subject: [PATCH 311/493] PEP fixes

---
 InvenTree/InvenTree/urls.py                                    | 3 +++
 .../plugin/samples/integration/test_samples_integration.py     | 1 -
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 1584e9f5ab..1347809dd7 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -124,6 +124,8 @@ translated_javascript_urls = [
 
 # Integration plugin urls
 integration_urls = []
+
+
 def get_integration_urls():
     urls = []
     for plugin in settings.INTEGRATION_PLUGINS.values():
@@ -131,6 +133,7 @@ def get_integration_urls():
             urls.append(plugin.urlpatterns)
     return urls
 
+
 try:
     if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
         integration_urls = get_integration_urls()
diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_samples_integration.py
index 8e2ef41634..733e443638 100644
--- a/InvenTree/plugin/samples/integration/test_samples_integration.py
+++ b/InvenTree/plugin/samples/integration/test_samples_integration.py
@@ -2,7 +2,6 @@
 
 from django.test import TestCase
 from django.contrib.auth import get_user_model
-from django.conf import settings
 
 
 class SampleIntegrationPluginTests(TestCase):

From b783ec566c313c1c689a41351d63e3bd6f0e5212 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 22:07:30 +0100
Subject: [PATCH 312/493] add maintenance mode

---
 InvenTree/InvenTree/settings.py | 15 ++++++++++++++-
 InvenTree/plugin/apps.py        | 27 ++++++++++++++++++++++++---
 InvenTree/templates/503.html    | 14 ++++++++++++++
 requirements.txt                |  1 +
 4 files changed, 53 insertions(+), 4 deletions(-)
 create mode 100644 InvenTree/templates/503.html

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index b49dbfbe15..1b79eac59f 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -123,6 +123,11 @@ LOGGING = {
         'handlers': ['console'],
         'level': log_level,
     },
+    'filters': {
+        'require_not_maintenance_mode_503': {
+            '()': 'maintenance_mode.logging.RequireNotMaintenanceMode503',
+        },
+    },
 }
 
 # Get a logger instance for this setup file
@@ -252,6 +257,9 @@ INSTALLED_APPS = [
     'django.contrib.staticfiles',
     'django.contrib.sites',
 
+    # Maintenance
+    'maintenance_mode',
+
     # InvenTree apps
     'build.apps.BuildConfig',
     'common.apps.CommonConfig',
@@ -298,7 +306,8 @@ MIDDLEWARE = CONFIG.get('middleware', [
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
-    'InvenTree.middleware.AuthRequiredMiddleware'
+    'InvenTree.middleware.AuthRequiredMiddleware',
+    'maintenance_mode.middleware.MaintenanceModeMiddleware'
 ])
 
 # Error reporting middleware
@@ -848,6 +857,10 @@ MARKDOWNIFY_WHITELIST_ATTRS = [
 
 MARKDOWNIFY_BLEACH = False
 
+# Maintenance mode
+MAINTENANCE_MODE_RETRY_AFTER = 60
+
+
 # Plugins
 PLUGIN_URL = 'plugin'
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 89ce89ae88..d944c50137 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -17,6 +17,9 @@ try:
 except:
     import importlib_metadata as metadata
 
+from maintenance_mode.core import maintenance_mode_on
+from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
+
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
 
@@ -35,6 +38,11 @@ class PluginAppConfig(AppConfig):
     def load_plugins(self):
         """load and activate all IntegrationPlugins"""
         logger.info('Start loading plugins')
+        # set maintanace mode
+        _maintenance = get_maintenance_mode()
+        if not _maintenance:
+            set_maintenance_mode(True)
+
         try:
             # we are using the db so for migrations etc we need to try this block
             self._init_plugins()
@@ -42,11 +50,20 @@ class PluginAppConfig(AppConfig):
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
             logger.info('Database not accessible while loading plugins')
+
+        # remove maintenance
+        if not _maintenance:
+            set_maintenance_mode(False)
         logger.info('Finished loading plugins')
 
     def unload_plugins(self):
         """unload and deactivate all IntegrationPlugins"""
         logger.info('Start unloading plugins')
+        # set maintanace mode
+        _maintenance = get_maintenance_mode()
+        if not _maintenance:
+            set_maintenance_mode(True)
+
         # remove all plugins from registry
         # plugins = settings.INTEGRATION_PLUGINS
         settings.INTEGRATION_PLUGINS = {}
@@ -55,14 +72,18 @@ class PluginAppConfig(AppConfig):
 
         # deactivate all integrations
         self._deactivate_plugins()
+
+        # remove maintenance
+        if not _maintenance:
+            set_maintenance_mode(False)
         logger.info('Finished unloading plugins')
 
     def reload_plugins(self):
         """safely reload IntegrationPlugins"""
-        # TODO check if the system is in maintainance mode before reloading
         logger.info('Start reloading plugins')
-        self.unload_plugins()
-        self.load_plugins()
+        with maintenance_mode_on():
+            self.unload_plugins()
+            self.load_plugins()
         logger.info('Finished reloading plugins')
     # endregion
 
diff --git a/InvenTree/templates/503.html b/InvenTree/templates/503.html
new file mode 100644
index 0000000000..f7381aeb69
--- /dev/null
+++ b/InvenTree/templates/503.html
@@ -0,0 +1,14 @@
+{% extends "account/base.html" %}
+{% load i18n %}
+
+{% block head_title %}
+{% trans "Page is in Maintenance" %}
+{% endblock %}
+
+{% block content %}
+    <h3>{% trans "The Page is in currently in maintenance mode" %}</h3>
+
+    <div class='alert alert-danger alert-block'>
+        {% trans "This page will reload each minute until the page becomes available again." %}
+    </div>
+{% endblock %}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 155cd49e14..573705b78e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -15,6 +15,7 @@ django-error-report==0.2.0      # Error report viewer for the admin interface
 django-filter==2.4.0            # Extended filtering options
 django-formtools==2.3           # Form wizard tools
 django-import-export==2.5.0     # Data import / export for admin interface
+django-maintenance-mode==0.16.1 # Shut down application while reloading etc.
 django-markdownify==0.8.0       # Markdown rendering
 django-markdownx==3.0.1         # Markdown form fields
 django-money==1.1               # Django app for currency management

From e52dd4828a11ade433a910920a325db295d375b4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 23:48:49 +0100
Subject: [PATCH 313/493] customize 503

---
 InvenTree/templates/503.html       | 19 +++---
 InvenTree/templates/auth_base.html | 69 +++++++++++++++++++++
 InvenTree/templates/skeleton.html  | 99 ++++++++++++++++++++++++++++++
 3 files changed, 179 insertions(+), 8 deletions(-)
 create mode 100644 InvenTree/templates/auth_base.html
 create mode 100644 InvenTree/templates/skeleton.html

diff --git a/InvenTree/templates/503.html b/InvenTree/templates/503.html
index f7381aeb69..8cef12212d 100644
--- a/InvenTree/templates/503.html
+++ b/InvenTree/templates/503.html
@@ -1,14 +1,17 @@
-{% extends "account/base.html" %}
+{% extends "auth_base.html" %}
 {% load i18n %}
 
-{% block head_title %}
-{% trans "Page is in Maintenance" %}
+
+{% block head %}
+<meta http-equiv="refresh" content="30">
 {% endblock %}
 
-{% block content %}
-    <h3>{% trans "The Page is in currently in maintenance mode" %}</h3>
+{% block page_title %}
+    {% trans 'Site is in Maintenance' %}
+{% endblock %}
 
-    <div class='alert alert-danger alert-block'>
-        {% trans "This page will reload each minute until the page becomes available again." %}
-    </div>
+{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}
+
+{% block content %}
+{% trans 'The site is currently in maintenance and should be up again soon!' %}
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/auth_base.html b/InvenTree/templates/auth_base.html
new file mode 100644
index 0000000000..603b417e78
--- /dev/null
+++ b/InvenTree/templates/auth_base.html
@@ -0,0 +1,69 @@
+{% extends "skeleton.html" %}
+{% load static %}
+{% load i18n %}
+{% load inventree_extras %}
+
+{% block page_title %}
+{% inventree_title %} | {% block head_title %}{% endblock %}
+{% endblock %}
+
+{% block body_class %}login-screen{% endblock %}
+
+{% block body %}
+    <!-- 
+        Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
+    -->
+    <div class='container-fluid'>
+        <div class='notification-area' id='alerts'>
+            <!-- Div for displayed alerts -->
+        </div>
+    </div>
+
+    <div class='main body-wrapper login-screen d-flex'>
+
+
+        <div class='login-container'>
+        <div class="row">
+            <div class='container-fluid'>
+
+                <div class='clearfix content-heading login-header d-flex flex-wrap'>
+                    <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
+                    {% include "spacer.html" %}
+                    <span class='float-right'><h3>{% block body_title %}{% inventree_title %}{% endblock %}</h3></span>
+                </div>
+            </div>
+                <div class='container-fluid'>
+                    <hr>
+                    {% block content %}
+                    {% endblock %}
+                </div>
+        </div>
+        </div>
+
+        {% block extra_body %}
+        {% endblock %}
+    </div>
+{% endblock %}
+
+{% block js_base %}
+<script type='text/javascript'>
+$(document).ready(function () {
+    // notifications
+    {% if messages %}
+    {% for message in messages %}
+    showAlertOrCache(
+        '{{ message }}',
+        true,
+        {
+            style: 'info',
+        }
+    );
+    {% endfor %}
+    {% endif %}
+
+    inventreeDocReady();
+});
+</script>
+{% endblock %}
+</body>
+</html>
\ No newline at end of file
diff --git a/InvenTree/templates/skeleton.html b/InvenTree/templates/skeleton.html
new file mode 100644
index 0000000000..ffd634e4f4
--- /dev/null
+++ b/InvenTree/templates/skeleton.html
@@ -0,0 +1,99 @@
+{% load static %}
+{% load i18n %}
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+
+<!-- Required meta tags -->
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+<!-- Favicon -->
+<link rel="apple-touch-icon" sizes="57x57" href="{% static 'img/favicon/apple-icon-57x57.png' %}">
+<link rel="apple-touch-icon" sizes="60x60" href="{% static 'img/favicon/apple-icon-60x60.png' %}">
+<link rel="apple-touch-icon" sizes="72x72" href="{% static 'img/favicon/apple-icon-72x72.png' %}">
+<link rel="apple-touch-icon" sizes="76x76" href="{% static 'img/favicon/apple-icon-76x76.png' %}">
+<link rel="apple-touch-icon" sizes="114x114" href="{% static 'img/favicon/apple-icon-114x114.png' %}">
+<link rel="apple-touch-icon" sizes="120x120" href="{% static 'img/favicon/apple-icon-120x120.png' %}">
+<link rel="apple-touch-icon" sizes="144x144" href="{% static 'img/favicon/apple-icon-144x144.png' %}">
+<link rel="apple-touch-icon" sizes="152x152" href="{% static 'img/favicon/apple-icon-152x152.png' %}">
+<link rel="apple-touch-icon" sizes="180x180" href="{% static 'img/favicon/apple-icon-180x180.png' %}">
+<link rel="icon" type="image/png" sizes="192x192"  href="{% static 'img/favicon/android-icon-192x192.png' %}">
+<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
+<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
+<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
+<link rel="manifest" href="{% static 'img/favicon/manifest.json' %}">
+<meta name="msapplication-TileColor" content="#ffffff">
+<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
+<meta name="theme-color" content="#ffffff">
+
+
+<!-- CSS -->
+<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
+<link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}">
+<link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}">
+<link rel="stylesheet" href="{% static 'select2/css/select2.css' %}">
+<link rel="stylesheet" href="{% static 'select2/css/select2-bootstrap-5-theme.css' %}">
+<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
+
+{% block head_css %}
+{% endblock %}
+
+<style>
+    {% block css %}
+    {% endblock %}
+</style>
+
+{% block head %}
+{% endblock %}
+
+<title>
+{% block page_title %}
+{% endblock %}
+</title>
+</head>
+
+<body class='{% block body_class %}{% endblock %}'>
+{% block body %}
+{% endblock %}
+
+<!-- Scripts -->
+<script type="text/javascript" src="{% static 'script/jquery_3.3.1_jquery.min.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/jquery-ui/jquery-ui.min.js' %}"></script>
+<script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
+{% block body_scripts_general %}
+{% endblock %}
+
+
+<!-- 3rd party general js -->
+<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
+<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
+<script type="text/javascript" src="{% static 'select2/js/select2.full.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
+
+<!-- general InvenTree -->
+<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
+{% block body_scripts_inventree %}
+{% endblock %}
+
+<!-- fontawesome -->
+<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
+<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
+<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script>
+
+{% block js_load %}
+{% endblock %}
+
+{% block js_base %}
+{% endblock %}
+
+{% block js %}
+{% endblock %}
+
+</body>
+</html>
\ No newline at end of file

From bf0129788d065364cab86c1fcb3358f337f6de7f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 15 Nov 2021 23:50:41 +0100
Subject: [PATCH 314/493] gitignore for locking file

---
 .gitignore | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index 420524d06f..6be8edd373 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,4 +79,7 @@ locale_stats.json
 # node.js
 package-lock.json
 package.json
-node_modules/
\ No newline at end of file
+node_modules/
+
+# maintenance locker
+maintenance_mode_state.txt

From 35d2259edf9b1ffa4f12b77327f1ebb7dd33ef9a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:05:53 +0100
Subject: [PATCH 315/493] added settings actions

---
 InvenTree/plugin/admin.py  | 22 ++++++++++++++++++++++
 InvenTree/plugin/models.py | 11 +++++++----
 2 files changed, 29 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index f877cbebf7..25b48ae5ce 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -2,13 +2,35 @@
 from __future__ import unicode_literals
 
 from django.contrib import admin
+from django.apps import apps
 
 import plugin.models as models
 
 
+def plugin_update(queryset, new_status: bool):
+    for model in queryset:
+        model.active = new_status
+        model.save(no_reload=True)
+
+    app = apps.get_app_config('plugin')
+    app.reload_plugins()
+
+
+@admin.action(description='Activate plugin(s)')
+def plugin_activate(modeladmin, request, queryset):
+    plugin_update(queryset, True)
+
+
+@admin.action(description='Deactivate plugin(s)')
+def plugin_deactivate(modeladmin, request, queryset):
+    plugin_update(queryset, False)
+
+
 class PluginConfigAdmin(admin.ModelAdmin):
     """Custom admin with restricted id fields"""
     readonly_fields = ["key", "name", ]
+    list_display = ['key', 'name', 'active', ]
+    actions = [plugin_activate, plugin_deactivate, ]
 
 
 admin.site.register(models.PluginConfig, PluginConfigAdmin)
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index c29e532789..259127c747 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -55,13 +55,16 @@ 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"""
+        reload = kwargs.pop('no_reload', False)  # check if no_reload flag is set
+
         ret = super().save(force_insert, force_update, *args, **kwargs)
         app = apps.get_app_config('plugin')
 
-        if self.active is False and self.__org_active is True:
-            app.reload_plugins()
+        if not reload:
+            if self.active is False and self.__org_active is True:
+                app.reload_plugins()
 
-        elif self.active is True and self.__org_active is False:
-            app.reload_plugins()
+            elif self.active is True and self.__org_active is False:
+                app.reload_plugins()
 
         return ret

From 3b0a004d6ed6101123a3083b47c7ad85b7feec83 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:09:38 +0100
Subject: [PATCH 316/493] update docsstrings

---
 InvenTree/plugin/admin.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 25b48ae5ce..16cf3262cc 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -8,6 +8,7 @@ import plugin.models as models
 
 
 def plugin_update(queryset, new_status: bool):
+    """general function for bulk changing plugins"""
     for model in queryset:
         model.active = new_status
         model.save(no_reload=True)
@@ -18,11 +19,13 @@ def plugin_update(queryset, new_status: bool):
 
 @admin.action(description='Activate plugin(s)')
 def plugin_activate(modeladmin, request, queryset):
+    """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"""
     plugin_update(queryset, False)
 
 

From 1794fb8865241e22a5af30020111471ea00a6250 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:12:58 +0100
Subject: [PATCH 317/493] check if you the plugins really need to be reloaded

---
 InvenTree/plugin/admin.py | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 16cf3262cc..cef5aa6133 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -9,12 +9,19 @@ import plugin.models as models
 
 def plugin_update(queryset, new_status: bool):
     """general function for bulk changing plugins"""
+    apps_changed = False
+
+    # run through all plugins in the queryset as the save method needs to be overridden
     for model in queryset:
-        model.active = new_status
+        if model.active is not new_status:
+            model.active = new_status
+            apps_changed = True
         model.save(no_reload=True)
 
-    app = apps.get_app_config('plugin')
-    app.reload_plugins()
+    # reload plugins if they changed
+    if apps_changed:
+        app = apps.get_app_config('plugin')
+        app.reload_plugins()
 
 
 @admin.action(description='Activate plugin(s)')

From bc7977863932c6c18915d519a24d9762ae8686ad Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:14:44 +0100
Subject: [PATCH 318/493] refactor

---
 InvenTree/plugin/admin.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index cef5aa6133..5a3bd335b3 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -12,11 +12,11 @@ def plugin_update(queryset, new_status: bool):
     apps_changed = False
 
     # run through all plugins in the queryset as the save method needs to be overridden
-    for model in queryset:
-        if model.active is not new_status:
-            model.active = new_status
+    for plugin in queryset:
+        if plugin.active is not new_status:
+            plugin.active = new_status
+            plugin.save(no_reload=True)
             apps_changed = True
-        model.save(no_reload=True)
 
     # reload plugins if they changed
     if apps_changed:

From f460780e392b5638cc51b44111964e906e73c50e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:17:44 +0100
Subject: [PATCH 319/493] reorder list display

---
 InvenTree/plugin/admin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 5a3bd335b3..7bfa1018f6 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -39,7 +39,7 @@ def plugin_deactivate(modeladmin, request, queryset):
 class PluginConfigAdmin(admin.ModelAdmin):
     """Custom admin with restricted id fields"""
     readonly_fields = ["key", "name", ]
-    list_display = ['key', 'name', 'active', ]
+    list_display = ['active', '__str__', 'key', 'name', ]
     actions = [plugin_activate, plugin_deactivate, ]
 
 

From aec6a58cad98e43449d6217bc55f09cc19528477 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:18:06 +0100
Subject: [PATCH 320/493] add filter to admin

---
 InvenTree/plugin/admin.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 7bfa1018f6..c96936f015 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -40,6 +40,7 @@ class PluginConfigAdmin(admin.ModelAdmin):
     """Custom admin with restricted id fields"""
     readonly_fields = ["key", "name", ]
     list_display = ['active', '__str__', 'key', 'name', ]
+    list_filter  = ['active']
     actions = [plugin_activate, plugin_deactivate, ]
 
 

From 65764effbbe70583b486937e74f47763e99fd4a5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:21:03 +0100
Subject: [PATCH 321/493] add verbose names to model

---
 InvenTree/plugin/models.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 259127c747..f162cfd121 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -20,6 +20,9 @@ class PluginConfig(models.Model):
         name: PluginName of the plugin - serves for a manual double check  if the right plugin is used
         active: Should the plugin be loaded?
     """
+    class Meta:
+        verbose_name = _("Plugin Configuration")
+        verbose_name_plural = _("Plugin Configurations")
 
     key = models.CharField(
         unique=True,

From 53422517edaf8d4301994cabf74a58397496829c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:21:14 +0100
Subject: [PATCH 322/493] PEP fixes

---
 InvenTree/plugin/admin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index c96936f015..beef26a20e 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -40,7 +40,7 @@ class PluginConfigAdmin(admin.ModelAdmin):
     """Custom admin with restricted id fields"""
     readonly_fields = ["key", "name", ]
     list_display = ['active', '__str__', 'key', 'name', ]
-    list_filter  = ['active']
+    list_filter = ['active']
     actions = [plugin_activate, plugin_deactivate, ]
 
 

From 2188025a938edc1390748eedd18246f756764978 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:23:23 +0100
Subject: [PATCH 323/493] refactor meta names

---
 InvenTree/plugin/integration.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 4d617c3c56..62ad3b4d35 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -36,7 +36,7 @@ class MixinBase:
     def setup_mixin(self, key, cls=None):
         """define mixin details for the current mixin -> provides meta details for all active mixins"""
         # get human name
-        human_name = getattr(cls.Meta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'Meta') else key
+        human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
 
         # register
         self._mixinreg[key] = {
@@ -59,7 +59,7 @@ class MixinBase:
 
 class GlobalSettingsMixin:
     """Mixin that enables global settings for the plugin"""
-    class Meta:
+    class MixinMeta:
         """meta options for this mixin"""
         MIXIN_NAME = 'Global settings'
 
@@ -111,7 +111,7 @@ class GlobalSettingsMixin:
 
 class UrlsMixin:
     """Mixin that enables urls for the plugin"""
-    class Meta:
+    class MixinMeta:
         """meta options for this mixin"""
         MIXIN_NAME = 'URLs'
 
@@ -162,7 +162,7 @@ class NavigationMixin:
     NAVIGATION_TAB_NAME = None
     NAVIGATION_TAB_ICON = "fas fa-question"
 
-    class Meta:
+    class MixinMeta:
         """meta options for this mixin"""
         MIXIN_NAME = 'Navigation Links'
 
@@ -206,7 +206,7 @@ class NavigationMixin:
 
 class AppMixin:
     """Mixin that enables full django app functions for a plugin"""
-    class Meta:
+    class MixinMeta:
         """meta options for this mixin"""
         MIXIN_NAME = 'App registration'
 

From 40cf7869d3c0cec9f7cd75604ab64db8d3ba6ff7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:40:49 +0100
Subject: [PATCH 324/493] refactor mixin locations

---
 .../plugin/builtin/integration/__init__.py    |   0
 .../plugin/builtin/integration/mixins.py      | 168 +++++++++++++++++
 InvenTree/plugin/integration.py               | 169 ------------------
 .../samples/integration/another_sample.py     |   3 +-
 .../plugin/samples/integration/sample.py      |   3 +-
 InvenTree/plugin/test_integration.py          |   3 +-
 6 files changed, 174 insertions(+), 172 deletions(-)
 create mode 100644 InvenTree/plugin/builtin/integration/__init__.py
 create mode 100644 InvenTree/plugin/builtin/integration/mixins.py

diff --git a/InvenTree/plugin/builtin/integration/__init__.py b/InvenTree/plugin/builtin/integration/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
new file mode 100644
index 0000000000..d807f931e5
--- /dev/null
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -0,0 +1,168 @@
+"""default shpping mixins for IntegrationMixins"""
+from django.conf import settings
+from django.conf.urls import url, include
+
+
+class GlobalSettingsMixin:
+    """Mixin that enables global settings for the plugin"""
+    class MixinMeta:
+        """meta options for this mixin"""
+        MIXIN_NAME = 'Global settings'
+
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('globalsettings', 'has_globalsettings', __class__)
+        self.globalsettings = self.setup_globalsettings()
+
+    def setup_globalsettings(self):
+        """
+        setup global settings for this plugin
+        """
+        return getattr(self, 'GLOBALSETTINGS', None)
+
+    @property
+    def has_globalsettings(self):
+        """
+        does this plugin use custom global settings
+        """
+        return bool(self.globalsettings)
+
+    @property
+    def globalsettingspatterns(self):
+        """
+        get patterns for InvenTreeSetting defintion
+        """
+        if self.has_globalsettings:
+            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}'
+
+    def get_globalsetting(self, key):
+        """
+        get plugin global setting by key
+        """
+        from common.models import InvenTreeSetting
+        return InvenTreeSetting.get_setting(self._globalsetting_name(key))
+
+    def set_globalsetting(self, key, value, user):
+        """
+        set plugin global setting by key
+        """
+        from common.models import InvenTreeSetting
+        return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
+
+
+class UrlsMixin:
+    """Mixin that enables urls for the plugin"""
+    class MixinMeta:
+        """meta options for this mixin"""
+        MIXIN_NAME = 'URLs'
+
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('urls', 'has_urls', __class__)
+        self.urls = self.setup_urls()
+
+    def setup_urls(self):
+        """
+        setup url endpoints for this plugin
+        """
+        return getattr(self, 'URLS', None)
+
+    @property
+    def base_url(self):
+        """
+        returns base url for this plugin
+        """
+        return f'{settings.PLUGIN_URL}/{self.slug}/'
+
+    @property
+    def internal_name(self):
+        """
+        returns the internal url pattern name
+        """
+        return f'plugin:{self.slug}:'
+
+    @property
+    def urlpatterns(self):
+        """
+        returns the urlpatterns for this plugin
+        """
+        if self.has_urls:
+            return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug)
+        return None
+
+    @property
+    def has_urls(self):
+        """
+        does this plugin use custom urls
+        """
+        return bool(self.urls)
+
+
+class NavigationMixin:
+    """Mixin that enables adding navigation links with the plugin"""
+    NAVIGATION_TAB_NAME = None
+    NAVIGATION_TAB_ICON = "fas fa-question"
+
+    class MixinMeta:
+        """meta options for this mixin"""
+        MIXIN_NAME = 'Navigation Links'
+
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('navigation', 'has_naviation', __class__)
+        self.navigation = self.setup_navigation()
+
+    def setup_navigation(self):
+        """
+        setup navigation links for this plugin
+        """
+        nav_links = getattr(self, 'NAVIGATION', None)
+        if nav_links:
+            # check if needed values are configured
+            for link in nav_links:
+                if False in [a in link for a in ('link', 'name', )]:
+                    raise NotImplementedError('Wrong Link definition', link)
+        return nav_links
+
+    @property
+    def has_naviation(self):
+        """
+        does this plugin define navigation elements
+        """
+        return bool(self.navigation)
+
+    @property
+    def navigation_name(self):
+        """name for navigation tab"""
+        name = getattr(self, 'NAVIGATION_TAB_NAME', None)
+        if not name:
+            name = self.human_name
+        return name
+
+    @property
+    def navigation_icon(self):
+        """icon for navigation tab"""
+        return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
+
+
+class AppMixin:
+    """Mixin that enables full django app functions for a plugin"""
+    class MixinMeta:
+        """meta options for this mixin"""
+        MIXIN_NAME = 'App registration'
+
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('app', 'has_app', __class__)
+
+    @property
+    def has_app(self):
+        """
+        this plugin is always an app with this plugin
+        """
+        return True
diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 62ad3b4d35..a06e4658ed 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -8,7 +8,6 @@ import inspect
 from datetime import datetime
 import pathlib
 
-from django.conf.urls import url, include
 from django.urls.base import reverse
 from django.conf import settings
 from django.utils.text import slugify
@@ -20,7 +19,6 @@ import plugin.plugin as plugin
 logger = logging.getLogger("inventree")
 
 
-# region mixins
 class MixinBase:
     """general base for mixins"""
 
@@ -57,173 +55,6 @@ class MixinBase:
         return mixins
 
 
-class GlobalSettingsMixin:
-    """Mixin that enables global settings for the plugin"""
-    class MixinMeta:
-        """meta options for this mixin"""
-        MIXIN_NAME = 'Global settings'
-
-    def __init__(self):
-        super().__init__()
-        self.add_mixin('globalsettings', 'has_globalsettings', __class__)
-        self.globalsettings = self.setup_globalsettings()
-
-    def setup_globalsettings(self):
-        """
-        setup global settings for this plugin
-        """
-        return getattr(self, 'GLOBALSETTINGS', None)
-
-    @property
-    def has_globalsettings(self):
-        """
-        does this plugin use custom global settings
-        """
-        return bool(self.globalsettings)
-
-    @property
-    def globalsettingspatterns(self):
-        """
-        get patterns for InvenTreeSetting defintion
-        """
-        if self.has_globalsettings:
-            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}'
-
-    def get_globalsetting(self, key):
-        """
-        get plugin global setting by key
-        """
-        from common.models import InvenTreeSetting
-        return InvenTreeSetting.get_setting(self._globalsetting_name(key))
-
-    def set_globalsetting(self, key, value, user):
-        """
-        set plugin global setting by key
-        """
-        from common.models import InvenTreeSetting
-        return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
-
-
-class UrlsMixin:
-    """Mixin that enables urls for the plugin"""
-    class MixinMeta:
-        """meta options for this mixin"""
-        MIXIN_NAME = 'URLs'
-
-    def __init__(self):
-        super().__init__()
-        self.add_mixin('urls', 'has_urls', __class__)
-        self.urls = self.setup_urls()
-
-    def setup_urls(self):
-        """
-        setup url endpoints for this plugin
-        """
-        return getattr(self, 'URLS', None)
-
-    @property
-    def base_url(self):
-        """
-        returns base url for this plugin
-        """
-        return f'{settings.PLUGIN_URL}/{self.slug}/'
-
-    @property
-    def internal_name(self):
-        """
-        returns the internal url pattern name
-        """
-        return f'plugin:{self.slug}:'
-
-    @property
-    def urlpatterns(self):
-        """
-        returns the urlpatterns for this plugin
-        """
-        if self.has_urls:
-            return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug)
-        return None
-
-    @property
-    def has_urls(self):
-        """
-        does this plugin use custom urls
-        """
-        return bool(self.urls)
-
-
-class NavigationMixin:
-    """Mixin that enables adding navigation links with the plugin"""
-    NAVIGATION_TAB_NAME = None
-    NAVIGATION_TAB_ICON = "fas fa-question"
-
-    class MixinMeta:
-        """meta options for this mixin"""
-        MIXIN_NAME = 'Navigation Links'
-
-    def __init__(self):
-        super().__init__()
-        self.add_mixin('navigation', 'has_naviation', __class__)
-        self.navigation = self.setup_navigation()
-
-    def setup_navigation(self):
-        """
-        setup navigation links for this plugin
-        """
-        nav_links = getattr(self, 'NAVIGATION', None)
-        if nav_links:
-            # check if needed values are configured
-            for link in nav_links:
-                if False in [a in link for a in ('link', 'name', )]:
-                    raise NotImplementedError('Wrong Link definition', link)
-        return nav_links
-
-    @property
-    def has_naviation(self):
-        """
-        does this plugin define navigation elements
-        """
-        return bool(self.navigation)
-
-    @property
-    def navigation_name(self):
-        """name for navigation tab"""
-        name = getattr(self, 'NAVIGATION_TAB_NAME', None)
-        if not name:
-            name = self.human_name
-        return name
-
-    @property
-    def navigation_icon(self):
-        """icon for navigation tab"""
-        return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
-
-
-class AppMixin:
-    """Mixin that enables full django app functions for a plugin"""
-    class MixinMeta:
-        """meta options for this mixin"""
-        MIXIN_NAME = 'App registration'
-
-    def __init__(self):
-        super().__init__()
-        self.add_mixin('app', 'has_app', __class__)
-
-    @property
-    def has_app(self):
-        """
-        this plugin is always an app with this plugin
-        """
-        return True
-
-# endregion
-
-
 # region git-helpers
 def get_git_log(path):
     """get dict with info of the last commit to file named in path"""
diff --git a/InvenTree/plugin/samples/integration/another_sample.py b/InvenTree/plugin/samples/integration/another_sample.py
index 5fe1daf30b..a82d90d1d0 100644
--- a/InvenTree/plugin/samples/integration/another_sample.py
+++ b/InvenTree/plugin/samples/integration/another_sample.py
@@ -1,5 +1,6 @@
 """sample implementation for IntegrationPlugin"""
-from plugin.integration import IntegrationPluginBase, UrlsMixin
+from plugin.integration import IntegrationPluginBase
+from plugin.builtin.integration.mixins import UrlsMixin
 
 
 class NoIntegrationPlugin(IntegrationPluginBase):
diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py
index bf99697b11..e6597498c1 100644
--- a/InvenTree/plugin/samples/integration/sample.py
+++ b/InvenTree/plugin/samples/integration/sample.py
@@ -1,5 +1,6 @@
 """sample implementations for IntegrationPlugin"""
-from plugin.integration import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase
+from plugin.integration import IntegrationPluginBase
+from plugin.builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 0391c3d35d..ed6e9760c8 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -7,7 +7,8 @@ from django.contrib.auth import get_user_model
 
 from datetime import datetime
 
-from plugin.integration import AppMixin, IntegrationPluginBase, GlobalSettingsMixin, UrlsMixin, NavigationMixin
+from plugin.integration import IntegrationPluginBase
+from plugin.builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
 
 
 class BaseMixinDefinition:

From b0f315dcba440963ab20ca5061bee7b5adbb818f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 16 Nov 2021 00:41:08 +0100
Subject: [PATCH 325/493] add missing migration

---
 .../0002_alter_pluginconfig_options.py          | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)
 create mode 100644 InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py

diff --git a/InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py b/InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py
new file mode 100644
index 0000000000..8917fa9b68
--- /dev/null
+++ b/InvenTree/plugin/migrations/0002_alter_pluginconfig_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.5 on 2021-11-15 23:39
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('plugin', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='pluginconfig',
+            options={'verbose_name': 'Plugin Configuration', 'verbose_name_plural': 'Plugin Configurations'},
+        ),
+    ]

From 5c741415886508fdf260d6d5422fbb5151aa4570 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:44:19 +0100
Subject: [PATCH 326/493] fully unregister app

---
 InvenTree/plugin/apps.py | 26 ++++++++++++++++++++------
 1 file changed, 20 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index d944c50137..b1781ecaa8 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -230,13 +230,27 @@ class PluginAppConfig(AppConfig):
         return plugin_path
 
     def deactivate_integration_app(self):
+        """deactivate integration app - some magic required"""
         # unregister models from admin
-        for app_name in settings.INTEGRATION_APPS_PATHS:
-            for model in apps.get_app_config(app_name.split('.')[-1]).get_models():
-                try:
-                    admin.site.unregister(model)
-                except:
-                    pass
+        for plugin_path in settings.INTEGRATION_APPS_PATHS:
+            models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed
+            app_name = plugin_path.split('.')[-1]
+
+            # check all models
+            for model in apps.get_app_config(app_name).get_models():
+                # remove model from admin site
+                admin.site.unregister(model)
+                models += [model._meta.model_name]
+
+            # unregister the models (yes, models are just kept in multilevel dicts)
+            for model in models:
+                # remove model from general registry
+                apps.all_models[plugin_path].pop(model)
+
+            # clear the registry for that app
+             # so that the import trick will work on reloading the same plugin
+             # -> the registry is kept for the whole lifecycle
+            apps.all_models.pop(app_name)
 
         # remove plugin from installed_apps
         for plugin in settings.INTEGRATION_APPS_PATHS:

From b563bbee00b0c0a3e599610ac803b4581871f52d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:45:48 +0100
Subject: [PATCH 327/493] fixes for reloading contrib apps

---
 InvenTree/plugin/apps.py | 28 +++++++++++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b1781ecaa8..1a5951e781 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -217,9 +217,35 @@ class PluginAppConfig(AppConfig):
                     settings.INTEGRATION_APPS_LOADING = False
                     self._reload_apps(populate=True)
                 self._reload_apps()
-                # update urls
+                self._reload_contrib()
                 self._update_urls()
 
+    def _reload_contrib(self):
+        """fix reloading of contrib apps - models and admin
+        this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
+        those register models and admin in their respective objects (e.g. admin.site for admin)
+        """
+        for plugin_path in settings.INTEGRATION_APPS_PATHS:
+            app_config = apps.get_app_config(plugin_path.split('.')[-1])
+
+            # reload models if they were set
+            # models_module gets set if models were defined - even after multiple loads
+            # on a reload the models registery is empty but models_module is not
+            if app_config.models_module and len(app_config.models) == 0:
+                reload(app_config.models_module)
+
+            # check for all models if they are registered with the site admin
+            model_not_reg = False
+            for model in app_config.get_models():
+                if not admin.site.is_registered(model):
+                    model_not_reg = True
+
+            # reload admin if at least one model is not registered
+            # models are registered with admin in the 'admin.py' file - so we check
+            # if the app_config has an admin module before trying to laod it
+            if model_not_reg and hasattr(app_config.module, 'admin'):
+                reload(app_config.module.admin)
+
     def _get_plugin_path(self, plugin):
         try:
             # for local path plugins

From a7279ce43e44048f415280edc8666bb9de889c91 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:46:12 +0100
Subject: [PATCH 328/493] streamlining

---
 InvenTree/plugin/apps.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 1a5951e781..784c30125a 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -283,11 +283,10 @@ class PluginAppConfig(AppConfig):
             settings.INSTALLED_APPS.remove(plugin)
 
         # reset load flag and reload apps
-        settings.INTEGRATION_APPS_PATHS = []
         settings.INTEGRATION_APPS_LOADED = False
         self._reload_apps()
 
-        # update urls
+        # update urls to remove the apps from the site admin
         self._update_urls()
 
     def _update_urls(self):

From 87082796299043caf136544fb916f4c282069d36 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:46:22 +0100
Subject: [PATCH 329/493] some more docs

---
 InvenTree/plugin/apps.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 784c30125a..6c0b153150 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -4,6 +4,7 @@ import importlib
 import pathlib
 import logging
 from typing import OrderedDict
+from importlib import reload
 
 from django.apps import AppConfig, apps
 from django.conf import settings
@@ -217,7 +218,9 @@ class PluginAppConfig(AppConfig):
                     settings.INTEGRATION_APPS_LOADING = False
                     self._reload_apps(populate=True)
                 self._reload_apps()
+                # rediscover models/ admin sites
                 self._reload_contrib()
+                # update urls - must be last as models must be registered for creating admin routes
                 self._update_urls()
 
     def _reload_contrib(self):
@@ -247,6 +250,11 @@ class PluginAppConfig(AppConfig):
                 reload(app_config.module.admin)
 
     def _get_plugin_path(self, plugin):
+        """parse plugin path
+        the input can be eiter:
+        - a local file / dir
+        - a package
+        """
         try:
             # for local path plugins
             plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)

From 3d2648ffb22414cdf370788df8ae3da247e2264b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:51:05 +0100
Subject: [PATCH 330/493] make deactivaton safe even if apps were not loaded
 rigth

---
 InvenTree/plugin/apps.py | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 6c0b153150..cf357e314f 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -269,12 +269,17 @@ class PluginAppConfig(AppConfig):
         for plugin_path in settings.INTEGRATION_APPS_PATHS:
             models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed
             app_name = plugin_path.split('.')[-1]
+            try:
+                app_config = apps.get_app_config(app_name)
 
-            # check all models
-            for model in apps.get_app_config(app_name).get_models():
-                # remove model from admin site
-                admin.site.unregister(model)
-                models += [model._meta.model_name]
+                # check all models
+                for model in app_config.get_models():
+                    # remove model from admin site
+                    admin.site.unregister(model)
+                    models += [model._meta.model_name]
+            except LookupError:
+                # if an error occurs the app was never loaded right -> so nothing to do anymore
+                break
 
             # unregister the models (yes, models are just kept in multilevel dicts)
             for model in models:

From c3ea0f0704e112a3a3934bafce7aca408cf22cf5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:51:13 +0100
Subject: [PATCH 331/493] indentations fix

---
 InvenTree/plugin/apps.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index cf357e314f..ba4aa575f8 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -287,8 +287,8 @@ class PluginAppConfig(AppConfig):
                 apps.all_models[plugin_path].pop(model)
 
             # clear the registry for that app
-             # so that the import trick will work on reloading the same plugin
-             # -> the registry is kept for the whole lifecycle
+            # so that the import trick will work on reloading the same plugin
+            # -> the registry is kept for the whole lifecycle
             apps.all_models.pop(app_name)
 
         # remove plugin from installed_apps

From 7c9ba1007d757484f1d31d9b47f74f22925dd78f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:51:47 +0100
Subject: [PATCH 332/493] refactor

---
 InvenTree/plugin/apps.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index ba4aa575f8..9232eda992 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -219,11 +219,11 @@ class PluginAppConfig(AppConfig):
                     self._reload_apps(populate=True)
                 self._reload_apps()
                 # rediscover models/ admin sites
-                self._reload_contrib()
+                self._reregister_contrib_apps()
                 # update urls - must be last as models must be registered for creating admin routes
                 self._update_urls()
 
-    def _reload_contrib(self):
+    def _reregister_contrib_apps(self):
         """fix reloading of contrib apps - models and admin
         this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
         those register models and admin in their respective objects (e.g. admin.site for admin)

From 6c5dd2a5a41def748c5d9e91583b5da6c23d94b4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 01:59:30 +0100
Subject: [PATCH 333/493] and safety here too

---
 InvenTree/plugin/apps.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 9232eda992..decbd333d2 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -229,7 +229,11 @@ class PluginAppConfig(AppConfig):
         those register models and admin in their respective objects (e.g. admin.site for admin)
         """
         for plugin_path in settings.INTEGRATION_APPS_PATHS:
-            app_config = apps.get_app_config(plugin_path.split('.')[-1])
+            try:
+                app_config = apps.get_app_config(plugin_path.split('.')[-1])
+            except LookupError:
+                # the plugin was never loaded correctly
+                break
 
             # reload models if they were set
             # models_module gets set if models were defined - even after multiple loads

From 4513ad5ab66c0d9470eaec8cbcb716de1a22a2fb Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:02:23 +0100
Subject: [PATCH 334/493] and this also

---
 InvenTree/plugin/apps.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index decbd333d2..5ed06c3dae 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -293,7 +293,8 @@ class PluginAppConfig(AppConfig):
             # clear the registry for that app
             # so that the import trick will work on reloading the same plugin
             # -> the registry is kept for the whole lifecycle
-            apps.all_models.pop(app_name)
+            if models:
+                apps.all_models.pop(app_name)
 
         # remove plugin from installed_apps
         for plugin in settings.INTEGRATION_APPS_PATHS:

From e121ad374b7d957c841a0c68a7d1db4a98ff1413 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:02:47 +0100
Subject: [PATCH 335/493] more safer = more better ::inno:

---
 InvenTree/plugin/apps.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 5ed06c3dae..739c207b54 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -293,7 +293,7 @@ class PluginAppConfig(AppConfig):
             # clear the registry for that app
             # so that the import trick will work on reloading the same plugin
             # -> the registry is kept for the whole lifecycle
-            if models:
+            if models and app_name in apps.all_models:
                 apps.all_models.pop(app_name)
 
         # remove plugin from installed_apps

From 0f321b8e83d650d9d40bc7ef8342a465f1a185d1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:03:03 +0100
Subject: [PATCH 336/493] turns out we needed that

---
 InvenTree/plugin/apps.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 739c207b54..b4f2d572ee 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -298,9 +298,11 @@ class PluginAppConfig(AppConfig):
 
         # remove plugin from installed_apps
         for plugin in settings.INTEGRATION_APPS_PATHS:
-            settings.INSTALLED_APPS.remove(plugin)
+            if plugin in settings.INSTALLED_APPS:
+                settings.INSTALLED_APPS.remove(plugin)
 
         # reset load flag and reload apps
+        settings.INTEGRATION_APPS_PATHS = []
         settings.INTEGRATION_APPS_LOADED = False
         self._reload_apps()
 

From 4ab464dc9e11c5cc0153a8c1c4b907aa6900444a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:07:13 +0100
Subject: [PATCH 337/493] refactor for debug if lookups fail

---
 InvenTree/plugin/apps.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b4f2d572ee..3da80f71ed 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -230,9 +230,11 @@ class PluginAppConfig(AppConfig):
         """
         for plugin_path in settings.INTEGRATION_APPS_PATHS:
             try:
-                app_config = apps.get_app_config(plugin_path.split('.')[-1])
+                app_name = plugin_path.split('.')[-1]
+                app_config = apps.get_app_config(app_name)
             except LookupError:
                 # the plugin was never loaded correctly
+                logger.debug(f'{app_name} App was not found during deregistering')
                 break
 
             # reload models if they were set

From fe96d07c1e69d1dce28e895f3dc5eaea861e637b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:07:29 +0100
Subject: [PATCH 338/493] log lookup error

---
 InvenTree/plugin/apps.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 3da80f71ed..c055aba1d8 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -285,6 +285,7 @@ class PluginAppConfig(AppConfig):
                     models += [model._meta.model_name]
             except LookupError:
                 # if an error occurs the app was never loaded right -> so nothing to do anymore
+                logger.debug(f'{app_name} App was not found during deregistering')
                 break
 
             # unregister the models (yes, models are just kept in multilevel dicts)

From 1b382a62ce76752fd76614e717c10989214c5417 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:18:06 +0100
Subject: [PATCH 339/493] git ignore plugins dir

---
 .gitignore | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/.gitignore b/.gitignore
index 6be8edd373..8db4f189ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -83,3 +83,6 @@ node_modules/
 
 # maintenance locker
 maintenance_mode_state.txt
+
+# plugin dev directory
+plugins/

From 8af4e81b42af7d5215450c05c147eec594f78373 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:19:06 +0100
Subject: [PATCH 340/493] remove unneeded file

---
 InvenTree/plugins/__init__.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 InvenTree/plugins/__init__.py

diff --git a/InvenTree/plugins/__init__.py b/InvenTree/plugins/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000

From e97af4c074c005574bec6d5c6250662f4be21a86 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 02:21:28 +0100
Subject: [PATCH 341/493] Revert "remove unneeded file"

This reverts commit 8af4e81b42af7d5215450c05c147eec594f78373.
---
 InvenTree/plugins/__init__.py | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 InvenTree/plugins/__init__.py

diff --git a/InvenTree/plugins/__init__.py b/InvenTree/plugins/__init__.py
new file mode 100644
index 0000000000..e69de29bb2

From df9d83b3d6611636998c26a92bf8a196e2d9d410 Mon Sep 17 00:00:00 2001
From: Matthias Mair <matmair@live.de>
Date: Wed, 17 Nov 2021 19:23:12 +0000
Subject: [PATCH 342/493] make tables responsive

---
 InvenTree/templates/InvenTree/settings/plugin.html          | 4 ++++
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 4 ++++
 2 files changed, 8 insertions(+)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index d996933007..923c1e5910 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -16,6 +16,7 @@
     {% trans "Changing the settings below require you to immediatly restart InvenTree. Do not change this while under active usage." %}
 </div>
 
+<div class='table-responsive'>
 <table class='table table-striped table-condensed'>
     <tbody>
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
@@ -24,12 +25,14 @@
         {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
     </tbody>
 </table>
+</div>
 
 <h4>{% trans "Plugin list" %}
     {% url 'admin:plugin_pluginconfig_changelist' as url %}
     {% include "admin_button.html" with url=url %}
 </h4>
 
+<div class='table-responsive'>
 <table class='table table-striped table-condensed'>
     <thead>
         <tr>
@@ -94,5 +97,6 @@
         {% endif %}
     </tbody>
 </table>
+</div>
 
 {% endblock %}
diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 3ae22371c2..8a1c81c013 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -15,6 +15,7 @@
 <div class="row">
     <div class="col-md-6">
         <h4>{% trans "Plugin information" %}</h4>
+        <div class='table-responsive'>
         <table class='table table-striped table-condensed'>
             <col width='25'>
             <tr>
@@ -63,6 +64,7 @@
             </tr>
             {% endif %}
         </table>
+        </div>
 
         {% if plugin.is_package == False %}
         <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
@@ -70,6 +72,7 @@
     </div>
     <div class="col-md-6">
         <h4>{% trans "Package information" %}</h4>
+        <div class='table-responsive'>
         <table class='table table-striped table-condensed'>
             <col width='25'>
             <tr>
@@ -117,6 +120,7 @@
                 <td class="bg-{{plugin.sign_color}}">{{ plugin.package.key }}{% include "clip.html" %}</td>
             </tr>
         </table>
+        </div>
     </div>
 </div>
 

From 958b47e58b276a45fedea829fb23f24b71428342 Mon Sep 17 00:00:00 2001
From: Matthias Mair <matmair@live.de>
Date: Wed, 17 Nov 2021 19:23:40 +0000
Subject: [PATCH 343/493] make cols responsive

---
 InvenTree/templates/InvenTree/settings/plugin_settings.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html
index 8a1c81c013..e3b1f18046 100644
--- a/InvenTree/templates/InvenTree/settings/plugin_settings.html
+++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html
@@ -13,7 +13,7 @@
 {% block content %}
 
 <div class="row">
-    <div class="col-md-6">
+    <div class="col">
         <h4>{% trans "Plugin information" %}</h4>
         <div class='table-responsive'>
         <table class='table table-striped table-condensed'>
@@ -70,7 +70,7 @@
         <p>{% trans 'The code information is pulled from the latest git commit for this plugin. It might not reflect official version numbers or information but the actual code running.' %}</p>
         {% endif %}
     </div>
-    <div class="col-md-6">
+    <div class="col">
         <h4>{% trans "Package information" %}</h4>
         <div class='table-responsive'>
         <table class='table table-striped table-condensed'>

From b0142de421a86cc59ae658905f042de9cef6781f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 23:16:05 +0100
Subject: [PATCH 344/493] adding API endpoints for plugins

---
 InvenTree/InvenTree/urls.py     |  2 ++
 InvenTree/plugin/api.py         | 45 +++++++++++++++++++++++++++++++++
 InvenTree/plugin/models.py      | 15 +++++++++++
 InvenTree/plugin/serializers.py | 28 ++++++++++++++++++++
 4 files changed, 90 insertions(+)
 create mode 100644 InvenTree/plugin/api.py
 create mode 100644 InvenTree/plugin/serializers.py

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 1347809dd7..6bd9ec1fb1 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -28,6 +28,7 @@ from build.api import build_api_urls
 from order.api import order_api_urls
 from label.api import label_api_urls
 from report.api import report_api_urls
+from plugin.api import plugin_api_urls
 
 from django.conf import settings
 from django.conf.urls.static import static
@@ -63,6 +64,7 @@ apipatterns = [
     url(r'^order/', include(order_api_urls)),
     url(r'^label/', include(label_api_urls)),
     url(r'^report/', include(report_api_urls)),
+    url(r'^plugin/', include(plugin_api_urls)),
 
     # User URLs
     url(r'^user/', include(user_urls)),
diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
new file mode 100644
index 0000000000..c6f7aadfbe
--- /dev/null
+++ b/InvenTree/plugin/api.py
@@ -0,0 +1,45 @@
+"""
+JSON API for the plugin app
+"""
+
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.conf.urls import url
+from django.utils.translation import ugettext_lazy as _
+
+from rest_framework import generics
+
+from plugin.models import PluginConfig
+import plugin.serializers as PluginSerializers
+
+
+class PluginList(generics.ListAPIView):
+    """ API endpoint for list of PluginConfig objects
+
+    - GET: Return a list of all PluginConfig objects
+    """
+
+    serializer_class = PluginSerializers.PluginConfigSerializer
+    queryset = PluginConfig.objects.all()
+
+    ordering_fields = [
+        'key',
+        'name',
+        'active',
+    ]
+
+    ordering = [
+        'key',
+    ]
+
+    search_fields = [
+        'key',
+        'name',
+    ]
+
+
+plugin_api_urls = [
+    # Anything else
+    url(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
+]
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index f162cfd121..2c9f00d5f8 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -8,6 +8,7 @@ from __future__ import unicode_literals
 from django.utils.translation import gettext_lazy as _
 from django.db import models
 from django.apps import apps
+from django.conf import settings
 
 
 class PluginConfig(models.Model):
@@ -51,11 +52,25 @@ class PluginConfig(models.Model):
             name += '(not active)'
         return name
 
+    # functions
+
     def __init__(self, *args, **kwargs):
         """override to set original state of"""
         super().__init__(*args, **kwargs)
         self.__org_active = self.active
 
+        # append settings from registry
+        self.plugin = settings.INTEGRATION_PLUGINS.get(self.key, None)
+
+        def get_plugin_meta(name):
+            if self.plugin:
+                return str(getattr(self.plugin, name, None))
+            return None
+
+        self.meta = {key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author', \
+            'pub_date', 'version', 'website', 'license', 'package_path', 'settings_url', ]}
+
+
     def save(self, force_insert=False, force_update=False, *args, **kwargs):
         """extend save method to reload plugins if the 'active' status changes"""
         reload = kwargs.pop('no_reload', False)  # check if no_reload flag is set
diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
new file mode 100644
index 0000000000..b5a2780529
--- /dev/null
+++ b/InvenTree/plugin/serializers.py
@@ -0,0 +1,28 @@
+"""
+JSON serializers for Stock app
+"""
+
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.utils.translation import ugettext_lazy as _
+
+from rest_framework import serializers
+
+from plugin.models import PluginConfig
+
+
+class PluginConfigSerializer(serializers.ModelSerializer):
+    """ Serializer for a PluginConfig:
+    """
+
+    meta = serializers.DictField(read_only=True)
+
+    class Meta:
+        model = PluginConfig
+        fields = [
+            'key',
+            'name',
+            'active',
+            'meta',
+        ]

From a996be3f5c33e24d692bac65515121d5f9993a6e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 23:21:05 +0100
Subject: [PATCH 345/493] always slugify key

---
 InvenTree/plugin/apps.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index c055aba1d8..eca59569a2 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -12,6 +12,7 @@ from django.db.utils import OperationalError, ProgrammingError
 from django.conf.urls import url, include
 from django.urls import clear_url_caches
 from django.contrib import admin
+from django.utils.text import slugify
 
 try:
     from importlib import metadata
@@ -123,6 +124,7 @@ class PluginAppConfig(AppConfig):
             # 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!
             try:
                 plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
             except (OperationalError, ProgrammingError) as error:

From 530227e15f5bfcb485fdac64cffd7bb8c94a4c3a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 23:35:56 +0100
Subject: [PATCH 346/493] add mixins to API

---
 InvenTree/plugin/models.py      | 4 ++++
 InvenTree/plugin/serializers.py | 2 ++
 2 files changed, 6 insertions(+)

diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 2c9f00d5f8..07c9641cfa 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -52,6 +52,10 @@ class PluginConfig(models.Model):
             name += '(not active)'
         return name
 
+    # extra attributes form the registry
+    def mixins(self):
+        return self.plugin._mixinreg
+
     # functions
 
     def __init__(self, *args, **kwargs):
diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index b5a2780529..b55828001c 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -17,6 +17,7 @@ class PluginConfigSerializer(serializers.ModelSerializer):
     """
 
     meta = serializers.DictField(read_only=True)
+    mixins = serializers.DictField(read_only=True)
 
     class Meta:
         model = PluginConfig
@@ -25,4 +26,5 @@ class PluginConfigSerializer(serializers.ModelSerializer):
             'name',
             'active',
             'meta',
+            'mixins',
         ]

From e728dc8fdfb0955844626cf0e1b26c294bc512b3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Wed, 17 Nov 2021 23:51:29 +0100
Subject: [PATCH 347/493] add detail endpoint

---
 InvenTree/plugin/api.py | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
index c6f7aadfbe..a1d07252f4 100644
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -39,7 +39,28 @@ class PluginList(generics.ListAPIView):
     ]
 
 
+class PluginDetail(generics.RetrieveUpdateDestroyAPIView):
+    """ API detail endpoint for PluginConfig object
+
+    get:
+    Return a single PluginConfig object
+
+    post:
+    Update a PluginConfig
+
+    delete:
+    Remove a PluginConfig
+    """
+
+    queryset = PluginConfig.objects.all()
+    serializer_class = PluginSerializers.PluginConfigSerializer
+
+
 plugin_api_urls = [
+    # Detail views for a single PluginConfig item
+    url(r'^(?P<pk>\d+)/', include([
+        url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
+    ])),
     # Anything else
     url(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
 ]

From a9fbfaf6afc714d07de3f0adbb2c5856d1788971 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:24:14 +0100
Subject: [PATCH 348/493] add installer endpoint

---
 InvenTree/plugin/api.py         | 13 +++++-
 InvenTree/plugin/serializers.py | 75 +++++++++++++++++++++++++++++++++
 2 files changed, 87 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
index a1d07252f4..4c05303ff6 100644
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -5,7 +5,7 @@ JSON API for the plugin app
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.conf.urls import url
+from django.conf.urls import url, include
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import generics
@@ -56,11 +56,22 @@ class PluginDetail(generics.RetrieveUpdateDestroyAPIView):
     serializer_class = PluginSerializers.PluginConfigSerializer
 
 
+class PluginInstall(generics.CreateAPIView):
+    """
+    Endpoint for installing a new plugin
+    """
+    queryset = PluginConfig.objects.none()
+    serializer_class = PluginSerializers.PluginConfigInstallSerializer
+
+
 plugin_api_urls = [
     # Detail views for a single PluginConfig item
     url(r'^(?P<pk>\d+)/', include([
         url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
     ])),
+
+    url(r'^install/', PluginInstall.as_view(), name='api-plugin-install'),
+
     # Anything else
     url(r'^.*$', PluginList.as_view(), name='api-plugin-list'),
 ]
diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index b55828001c..545e2375d6 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -5,6 +5,11 @@ JSON serializers for Stock app
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import os
+import subprocess
+
+from django.core.exceptions import ValidationError
+from django.conf import settings
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import serializers
@@ -28,3 +33,73 @@ class PluginConfigSerializer(serializers.ModelSerializer):
             'meta',
             'mixins',
         ]
+
+
+class PluginConfigInstallSerializer(serializers.Serializer):
+    """
+    Serializer for installing a new plugin
+    """
+
+    url = serializers.CharField(required=False)
+    packagename = serializers.CharField(required=False)
+    confirm = serializers.BooleanField()
+
+    class Meta:
+        fields = [
+            'url',
+            'packagename',
+            'confirm',
+        ]
+
+    def validate(self, data):
+        super().validate(data)
+
+        # check the base requirements are met
+        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')
+            raise ValidationError({'url': msg, 'packagename': msg})
+
+        return data
+
+    def save(self):
+        data = self.validated_data
+
+        packagename = data.get('packagename', '')
+        url = data.get('url', '')
+
+        # build up the command
+        command = 'python -m pip install'.split()
+
+        if url:
+            # use custom registration / VCS
+            if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', '']]:
+                # using a VCS provider
+                if packagename:
+                    command.append(f'{packagename}@{url}')
+                else:
+                    command.append(url)
+            else:
+                # using a custom package repositories
+                command.append('-i')
+                command.append(url)
+                command.append(packagename)
+
+        elif packagename:
+            # use pypi
+            command.append(packagename)
+
+        ret = {'command': command}
+        # execute pypi
+        try:
+            result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR))
+            ret['result'] = str(result, 'utf-8')
+        except subprocess.CalledProcessError as error:
+            ret['result'] = str(error.output, 'utf-8')
+            ret['error'] = True
+
+        # register plugins
+        # TODO
+
+        return ret

From 4effd76ca0d39e35f5fb3f9c1e96ca396818d85f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:24:30 +0100
Subject: [PATCH 349/493] spellfix

---
 InvenTree/plugin/models.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 07c9641cfa..adccf119fe 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -52,7 +52,7 @@ class PluginConfig(models.Model):
             name += '(not active)'
         return name
 
-    # extra attributes form the registry
+    # extra attributes from the registry
     def mixins(self):
         return self.plugin._mixinreg
 

From 392b7a468339011d869d8c554ccb1e8c913aa101 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:30:31 +0100
Subject: [PATCH 350/493] fix vcs check

---
 InvenTree/plugin/serializers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index 545e2375d6..8ca48ae5ca 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -74,7 +74,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
 
         if url:
             # use custom registration / VCS
-            if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', '']]:
+            if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]:
                 # using a VCS provider
                 if packagename:
                     command.append(f'{packagename}@{url}')

From cbcab9498afd794e2ddcb2eda0907688173c119b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:36:37 +0100
Subject: [PATCH 351/493] override return behaviour

---
 InvenTree/plugin/api.py | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
index 4c05303ff6..353683214d 100644
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -9,6 +9,8 @@ from django.conf.urls import url, include
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import generics
+from rest_framework import status
+from rest_framework.response import Response
 
 from plugin.models import PluginConfig
 import plugin.serializers as PluginSerializers
@@ -63,6 +65,17 @@ class PluginInstall(generics.CreateAPIView):
     queryset = PluginConfig.objects.none()
     serializer_class = PluginSerializers.PluginConfigInstallSerializer
 
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        result = self.perform_create(serializer)
+        result['data'] = serializer.data
+        headers = self.get_success_headers(serializer.data)
+        return Response(result, status=status.HTTP_201_CREATED, headers=headers)
+
+    def perform_create(self, serializer):
+        return serializer.save()
+
 
 plugin_api_urls = [
     # Detail views for a single PluginConfig item

From 95fbc27f10ed2f490a88175b1e773bb89cdfcf5e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:46:27 +0100
Subject: [PATCH 352/493] PEP fixes

---
 InvenTree/plugin/api.py    | 1 -
 InvenTree/plugin/models.py | 8 +++++---
 tasks.py                   | 2 ++
 3 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
index 353683214d..cac151db23 100644
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -6,7 +6,6 @@ JSON API for the plugin app
 from __future__ import unicode_literals
 
 from django.conf.urls import url, include
-from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import generics
 from rest_framework import status
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index adccf119fe..ab53ed52d8 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -71,9 +71,11 @@ class PluginConfig(models.Model):
                 return str(getattr(self.plugin, name, None))
             return None
 
-        self.meta = {key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author', \
-            'pub_date', 'version', 'website', 'license', 'package_path', 'settings_url', ]}
-
+        self.meta = {
+            key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author',
+                                                  'pub_date', 'version', 'website', 'license',
+                                                  'package_path', 'settings_url', ]
+        }
 
     def save(self, force_insert=False, force_update=False, *args, **kwargs):
         """extend save method to reload plugins if the 'active' status changes"""
diff --git a/tasks.py b/tasks.py
index 57b0e41ad9..b33af84384 100644
--- a/tasks.py
+++ b/tasks.py
@@ -133,6 +133,7 @@ def rebuild_models(c):
 
     manage(c, "rebuild_models", pty=True)
 
+
 @task
 def rebuild_thumbnails(c):
     """
@@ -141,6 +142,7 @@ def rebuild_thumbnails(c):
 
     manage(c, "rebuild_thumbnails", pty=True)
 
+
 @task
 def clean_settings(c):
     """

From 414051706083be29a929295845d6c445f903eb33 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:47:20 +0100
Subject: [PATCH 353/493] PEP fixes

---
 ci/check_version_number.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/ci/check_version_number.py b/ci/check_version_number.py
index a338798a0c..258c8780d8 100644
--- a/ci/check_version_number.py
+++ b/ci/check_version_number.py
@@ -9,7 +9,6 @@ import sys
 import re
 import os
 import argparse
-import requests
 
 if __name__ == '__main__':
 
@@ -65,7 +64,7 @@ if __name__ == '__main__':
         e.g. "0.5 dev"
         """
 
-        print(f"Checking development branch")
+        print("Checking development branch")
 
         pattern = "^\d+(\.\d+)+ dev$"
 
@@ -81,7 +80,7 @@ if __name__ == '__main__':
         e.g. "0.5.1"
         """
 
-        print(f"Checking release branch")
+        print("Checking release branch")
 
         pattern = "^\d+(\.\d+)+$"
 

From bff4623a15aeccbec1ea8e708c4d08842f7f45d4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 01:48:03 +0100
Subject: [PATCH 354/493] refactor

---
 InvenTree/plugin/api.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
index cac151db23..4aecd6bb24 100644
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -68,7 +68,7 @@ class PluginInstall(generics.CreateAPIView):
         serializer = self.get_serializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         result = self.perform_create(serializer)
-        result['data'] = serializer.data
+        result['input'] = serializer.data
         headers = self.get_success_headers(serializer.data)
         return Response(result, status=status.HTTP_201_CREATED, headers=headers)
 

From 000adb357d7bc527e419b5788f58b6b0192f3064 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 12:50:22 +0100
Subject: [PATCH 355/493] refactor plugin urls into plugin dir

---
 InvenTree/InvenTree/settings.py               |  2 --
 InvenTree/InvenTree/urls.py                   | 25 +++----------------
 InvenTree/plugin/apps.py                      |  6 +++--
 .../plugin/builtin/integration/mixins.py      |  4 ++-
 InvenTree/plugin/test_integration.py          |  3 ++-
 InvenTree/plugin/urls.py                      | 21 ++++++++++++++++
 6 files changed, 34 insertions(+), 27 deletions(-)
 create mode 100644 InvenTree/plugin/urls.py

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 7ce41b2d4c..f8a7f019db 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -871,8 +871,6 @@ MAINTENANCE_MODE_RETRY_AFTER = 60
 
 
 # Plugins
-PLUGIN_URL = 'plugin'
-
 PLUGIN_DIRS = ['plugin.builtin', ]
 
 if not TESTING:
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 6bd9ec1fb1..1f5e87d047 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -18,6 +18,7 @@ from part.urls import part_urls
 from stock.urls import stock_urls
 from build.urls import build_urls
 from order.urls import order_urls
+from plugin.urls import plugin_urls, PLUGIN_BASE
 
 from barcodes.api import barcode_api_urls
 from common.api import common_api_urls
@@ -124,25 +125,6 @@ translated_javascript_urls = [
     url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
 ]
 
-# Integration plugin urls
-integration_urls = []
-
-
-def get_integration_urls():
-    urls = []
-    for plugin in settings.INTEGRATION_PLUGINS.values():
-        if plugin.mixin_enabled('urls'):
-            urls.append(plugin.urlpatterns)
-    return urls
-
-
-try:
-    if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_URL'):
-        integration_urls = get_integration_urls()
-except (OperationalError, ProgrammingError):
-    # Exception if the database has not been migrated yet
-    pass
-
 urlpatterns = [
     url(r'^part/', include(part_urls)),
     url(r'^manufacturer-part/', include(manufacturer_part_urls)),
@@ -181,8 +163,9 @@ urlpatterns = [
     url(r'^api/', include(apipatterns)),
     url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
 
-    # plugins
-    url(f'^{settings.PLUGIN_URL}/', include((integration_urls, 'plugin'))),
+    # plugin urls
+    url(r'^plugins/', include(plugin_urls)),
+    url(f'^{PLUGIN_BASE}/', include(([], 'plugin'))),  # on startup we do not have any plugins enabled
 
     url(r'^markdownx/', include('markdownx.urls')),
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index eca59569a2..78d861d0a5 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -25,6 +25,7 @@ from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
 
+
 logger = logging.getLogger('inventree')
 
 
@@ -315,7 +316,8 @@ class PluginAppConfig(AppConfig):
         self._update_urls()
 
     def _update_urls(self):
-        from InvenTree.urls import urlpatterns, get_integration_urls
+        from InvenTree.urls import urlpatterns
+        from plugin.urls import PLUGIN_BASE, get_integration_urls
 
         for index, a in enumerate(urlpatterns):
             if hasattr(a, 'app_name'):
@@ -323,7 +325,7 @@ class PluginAppConfig(AppConfig):
                     urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
                 elif a.app_name == 'plugin':
                     integ_urls = get_integration_urls()
-                    urlpatterns[index] = url(f'^{settings.PLUGIN_URL}/', include((integ_urls, 'plugin')))
+                    urlpatterns[index] = url(f'^{PLUGIN_BASE}/', include((integ_urls, 'plugin')))
         clear_url_caches()
 
     def _reload_apps(self, populate: bool = False):
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index d807f931e5..54cfabe82d 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -2,6 +2,8 @@
 from django.conf import settings
 from django.conf.urls import url, include
 
+from plugin.urls import PLUGIN_BASE
+
 
 class GlobalSettingsMixin:
     """Mixin that enables global settings for the plugin"""
@@ -77,7 +79,7 @@ class UrlsMixin:
         """
         returns base url for this plugin
         """
-        return f'{settings.PLUGIN_URL}/{self.slug}/'
+        return f'{PLUGIN_BASE}/{self.slug}/'
 
     @property
     def internal_name(self):
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index ed6e9760c8..b9951a6831 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -9,6 +9,7 @@ from datetime import datetime
 
 from plugin.integration import IntegrationPluginBase
 from plugin.builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
+from plugin.urls import PLUGIN_BASE
 
 
 class BaseMixinDefinition:
@@ -82,7 +83,7 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase):
         plg_name = self.mixin.plugin_name()
 
         # base_url
-        target_url = f'{settings.PLUGIN_URL}/{plg_name}/'
+        target_url = f'{PLUGIN_BASE}/{plg_name}/'
         self.assertEqual(self.mixin.base_url, target_url)
 
         # urlpattern
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
new file mode 100644
index 0000000000..584cf56299
--- /dev/null
+++ b/InvenTree/plugin/urls.py
@@ -0,0 +1,21 @@
+"""
+URL lookup for plugin app
+"""
+
+from django.conf.urls import url, include
+from django.conf import settings
+
+
+PLUGIN_BASE = 'plugin'  # Constant for links
+
+
+def get_integration_urls():
+    urls = []
+    for plugin in settings.INTEGRATION_PLUGINS.values():
+        if plugin.mixin_enabled('urls'):
+            urls.append(plugin.urlpatterns)
+    return urls
+
+
+plugin_urls = [
+]

From 5aa146127cfd9584a34cb9e1f5c96af030b6ef52 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 13:13:59 +0100
Subject: [PATCH 356/493] PEP fix

---
 InvenTree/InvenTree/urls.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 1f5e87d047..5f815d889a 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -33,7 +33,6 @@ from plugin.api import plugin_api_urls
 
 from django.conf import settings
 from django.conf.urls.static import static
-from django.db.utils import OperationalError, ProgrammingError
 
 from django.views.generic.base import RedirectView
 from rest_framework.documentation import include_docs_urls
@@ -45,8 +44,6 @@ from .views import CurrencyRefreshView
 from .views import AppearanceSelectView, SettingCategorySelectView
 from .views import DynamicJsView
 
-from common.models import InvenTreeSetting
-
 from .api import InfoView, NotFoundView
 from .api import ActionPluginView
 

From 5dbc5d141a011b4c48f564d9bbdbcac7343cca6c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:16:25 +0100
Subject: [PATCH 357/493] add plugin install button

---
 InvenTree/InvenTree/urls.py                   |  1 +
 .../templates/InvenTree/settings/plugin.html  |  3 +++
 .../InvenTree/settings/settings.html          |  4 +++
 InvenTree/templates/base.html                 |  1 +
 InvenTree/templates/js/translated/plugin.js   | 26 +++++++++++++++++++
 5 files changed, 35 insertions(+)
 create mode 100644 InvenTree/templates/js/translated/plugin.js

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 5f815d889a..2e28ddf077 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -118,6 +118,7 @@ translated_javascript_urls = [
     url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
     url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
     url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
+    url(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
     url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
     url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
 ]
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 923c1e5910..920d0b7804 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -28,8 +28,11 @@
 </div>
 
 <h4>{% trans "Plugin list" %}
+<div id="page-actions" class="btn-group" role="group">
     {% url 'admin:plugin_pluginconfig_changelist' as url %}
     {% include "admin_button.html" with url=url %}
+    <button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button>
+</div>
 </h4>
 
 <div class='table-responsive'>
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index 8b625c2eff..91202e7c21 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -322,6 +322,10 @@ $("#import-part").click(function() {
     launchModalForm("{% url 'api-part-import' %}?reset", {});
 });
 
+$("#install-plugin").click(function() {
+    installPlugin();
+});
+
 enableSidebar('settings');
 
 {% endblock %}
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 262a749bfa..b052f68e45 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -177,6 +177,7 @@
 <script type='text/javascript' src="{% i18n_static 'part.js' %}"></script>
 <script type='text/javascript' src="{% i18n_static 'report.js' %}"></script>
 <script type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
+<script type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
 <script type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
 <script type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
 
diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js
new file mode 100644
index 0000000000..2f57631424
--- /dev/null
+++ b/InvenTree/templates/js/translated/plugin.js
@@ -0,0 +1,26 @@
+{% load i18n %}
+{% load inventree_extras %}
+
+/* globals
+    constructForm,
+*/
+
+/* exported
+    installPlugin,
+*/
+
+function installPlugin() {
+    constructForm(`/api/plugin/install/`, {
+        method: 'POST',
+        title: '{% trans "Install Plugin" %}',
+        fields: {
+            url: {},
+            packagename: {},
+            confirm: {},
+        },
+        onSuccess: function(data) {
+            msg = '{% trans "The Plugin was installed" %}';
+            showMessage(msg, {style: 'success', details: data.result, timeout: 30000});
+        }
+    });
+}
\ No newline at end of file

From 37f14f537afc044d9a604f25e2cbbc54078db61a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:17:26 +0100
Subject: [PATCH 358/493] make sure bool for maintenance sate

---
 InvenTree/plugin/apps.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 78d861d0a5..14f077847b 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -42,7 +42,7 @@ class PluginAppConfig(AppConfig):
         """load and activate all IntegrationPlugins"""
         logger.info('Start loading plugins')
         # set maintanace mode
-        _maintenance = get_maintenance_mode()
+        _maintenance = bool(get_maintenance_mode())
         if not _maintenance:
             set_maintenance_mode(True)
 
@@ -63,7 +63,7 @@ class PluginAppConfig(AppConfig):
         """unload and deactivate all IntegrationPlugins"""
         logger.info('Start unloading plugins')
         # set maintanace mode
-        _maintenance = get_maintenance_mode()
+        _maintenance = bool(get_maintenance_mode())
         if not _maintenance:
             set_maintenance_mode(True)
 

From 3af426bdd7d962d79f80bf3cbd92f4e6a2e73abc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:17:47 +0100
Subject: [PATCH 359/493] allow empty values -> submition from form

---
 InvenTree/plugin/serializers.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index 8ca48ae5ca..c02025e9ae 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -40,8 +40,8 @@ class PluginConfigInstallSerializer(serializers.Serializer):
     Serializer for installing a new plugin
     """
 
-    url = serializers.CharField(required=False)
-    packagename = serializers.CharField(required=False)
+    url = serializers.CharField(required=False, allow_blank=True)
+    packagename = serializers.CharField(required=False, allow_blank=True)
     confirm = serializers.BooleanField()
 
     class Meta:

From 6ab0e680000e08516deeb6bef0d6e74b5798c4ef Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:18:08 +0100
Subject: [PATCH 360/493] remove unneeded imports

---
 InvenTree/plugin/urls.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 584cf56299..558197cada 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -1,8 +1,6 @@
 """
 URL lookup for plugin app
 """
-
-from django.conf.urls import url, include
 from django.conf import settings
 
 

From efa2ad542d29d66bcf74036f7c5c807c0889a1bf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:19:46 +0100
Subject: [PATCH 361/493] add refactor

---
 InvenTree/plugin/serializers.py | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index c02025e9ae..edf40fd7e4 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -40,8 +40,14 @@ class PluginConfigInstallSerializer(serializers.Serializer):
     Serializer for installing a new plugin
     """
 
-    url = serializers.CharField(required=False, allow_blank=True)
-    packagename = serializers.CharField(required=False, allow_blank=True)
+    url = serializers.CharField(
+        required=False,
+        allow_blank=True,
+    )
+    packagename = serializers.CharField(
+        required=False,
+        allow_blank=True,
+    )
     confirm = serializers.BooleanField()
 
     class Meta:

From 0ece82c812932c6730b8c182c77fcdd53ab830ab Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:25:49 +0100
Subject: [PATCH 362/493] add labels / helptexts to serializer

---
 InvenTree/plugin/serializers.py | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index edf40fd7e4..20ff55052e 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -43,12 +43,19 @@ class PluginConfigInstallSerializer(serializers.Serializer):
     url = serializers.CharField(
         required=False,
         allow_blank=True,
+        label=_('source URL'),
+        help_text=_('Source for the package - this can be a custom registry or a VCS path')
     )
     packagename = serializers.CharField(
         required=False,
         allow_blank=True,
+        label=_('Package Name'),
+        help_text=_('Name for the Plugin Package - can also contain a version indicator'),
+    )
+    confirm = serializers.BooleanField(
+        label=_('Confirm plugin installation'),
+        help_text=_('This will install this plugin now into the current instance. The instance will go into maintenance.')
     )
-    confirm = serializers.BooleanField()
 
     class Meta:
         fields = [

From a617b8b1587e42fb323584c3102a21d6731996bc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:26:07 +0100
Subject: [PATCH 363/493] fix spelling

---
 InvenTree/plugin/serializers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index 20ff55052e..87b0bd7ae8 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -43,7 +43,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
     url = serializers.CharField(
         required=False,
         allow_blank=True,
-        label=_('source URL'),
+        label=_('Source URL'),
         help_text=_('Source for the package - this can be a custom registry or a VCS path')
     )
     packagename = serializers.CharField(

From d750e9e191b8566c29352dca700863f1637ac05d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:26:31 +0100
Subject: [PATCH 364/493] reorder fields

---
 InvenTree/templates/js/translated/plugin.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js
index 2f57631424..8675a491f0 100644
--- a/InvenTree/templates/js/translated/plugin.js
+++ b/InvenTree/templates/js/translated/plugin.js
@@ -14,8 +14,8 @@ function installPlugin() {
         method: 'POST',
         title: '{% trans "Install Plugin" %}',
         fields: {
-            url: {},
             packagename: {},
+            url: {},
             confirm: {},
         },
         onSuccess: function(data) {

From e5d0380356c21fda75ffbc2368db3da4a5dfdd34 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:35:16 +0100
Subject: [PATCH 365/493] PEP fixes finishes work for #2318

---
 InvenTree/plugin/builtin/integration/mixins.py | 1 -
 InvenTree/templates/js/translated/plugin.js    | 2 +-
 2 files changed, 1 insertion(+), 2 deletions(-)

diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index 54cfabe82d..edf9a2c474 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -1,5 +1,4 @@
 """default shpping mixins for IntegrationMixins"""
-from django.conf import settings
 from django.conf.urls import url, include
 
 from plugin.urls import PLUGIN_BASE
diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js
index 8675a491f0..c612dd1e8c 100644
--- a/InvenTree/templates/js/translated/plugin.js
+++ b/InvenTree/templates/js/translated/plugin.js
@@ -23,4 +23,4 @@ function installPlugin() {
             showMessage(msg, {style: 'success', details: data.result, timeout: 30000});
         }
     });
-}
\ No newline at end of file
+}

From 8cab9748931b29dc79163e721fd74cb4b2ddeaf2 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 18 Nov 2021 16:38:31 +0100
Subject: [PATCH 366/493] send command as string

---
 InvenTree/plugin/serializers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index 87b0bd7ae8..6e19eb2559 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -103,7 +103,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
             # use pypi
             command.append(packagename)
 
-        ret = {'command': command}
+        ret = {'command': ' '.join(command)}
         # execute pypi
         try:
             result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR))

From f6ff6c3e68a2bd05760f764be8df6b2149b9a246 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 22:49:24 +0100
Subject: [PATCH 367/493] remove webhook migrations

---
 .../common/migrations/0012_webhookendpoint.py | 28 -------------------
 .../migrations/0013_auto_20210912_1443.py     | 18 ------------
 .../migrations/0014_auto_20210912_1804.py     | 26 -----------------
 ...tificationentry_0014_auto_20210912_1804.py | 14 ----------
 4 files changed, 86 deletions(-)
 delete mode 100644 InvenTree/common/migrations/0012_webhookendpoint.py
 delete mode 100644 InvenTree/common/migrations/0013_auto_20210912_1443.py
 delete mode 100644 InvenTree/common/migrations/0014_auto_20210912_1804.py
 delete mode 100644 InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py

diff --git a/InvenTree/common/migrations/0012_webhookendpoint.py b/InvenTree/common/migrations/0012_webhookendpoint.py
deleted file mode 100644
index b3cf6bce2f..0000000000
--- a/InvenTree/common/migrations/0012_webhookendpoint.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Generated by Django 3.2.4 on 2021-09-12 12:42
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-import uuid
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('common', '0011_auto_20210722_2114'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='WebhookEndpoint',
-            fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('endpoint_id', models.CharField(default=uuid.uuid4, editable=False, help_text='Endpoint at which this webhook is received', max_length=255, verbose_name='Endpoint')),
-                ('name', models.CharField(blank=True, help_text='Name for this webhook', max_length=255, null=True, verbose_name='Name')),
-                ('active', models.BooleanField(default=True, help_text='Is this webhook active', verbose_name='Active')),
-                ('token', models.CharField(blank=True, default=uuid.uuid4, help_text='Token for access', max_length=255, null=True, verbose_name='Token')),
-                ('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
-            ],
-        ),
-    ]
diff --git a/InvenTree/common/migrations/0013_auto_20210912_1443.py b/InvenTree/common/migrations/0013_auto_20210912_1443.py
deleted file mode 100644
index f9c05fe05f..0000000000
--- a/InvenTree/common/migrations/0013_auto_20210912_1443.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 3.2.4 on 2021-09-12 14:43
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('common', '0012_webhookendpoint'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='webhookendpoint',
-            name='secret',
-            field=models.CharField(blank=True, help_text='Shared secret for HMAC', max_length=255, null=True, verbose_name='Secret'),
-        ),
-    ]
diff --git a/InvenTree/common/migrations/0014_auto_20210912_1804.py b/InvenTree/common/migrations/0014_auto_20210912_1804.py
deleted file mode 100644
index 18feb0d4a4..0000000000
--- a/InvenTree/common/migrations/0014_auto_20210912_1804.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# Generated by Django 3.2.4 on 2021-09-12 18:04
-
-from django.db import migrations, models
-import django.db.models.deletion
-import uuid
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('common', '0013_auto_20210912_1443'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='WebhookMessage',
-            fields=[
-                ('message_id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this message', primary_key=True, serialize=False, verbose_name='Message ID')),
-                ('host', models.CharField(editable=False, help_text='Host from which this message was received', max_length=255, verbose_name='Host')),
-                ('header', models.CharField(blank=True, editable=False, help_text='Header of this message', max_length=255, null=True, verbose_name='Header')),
-                ('body', models.JSONField(blank=True, editable=False, help_text='Body of this message', null=True, verbose_name='Body')),
-                ('worked_on', models.BooleanField(default=False, help_text='Was the work on this message finished?', verbose_name='Worked on')),
-                ('endpoint', models.ForeignKey(blank=True, help_text='Endpoint on which this message was received', null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.webhookendpoint', verbose_name='Endpoint')),
-            ],
-        ),
-    ]
diff --git a/InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py b/InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py
deleted file mode 100644
index 8d2d4d0b18..0000000000
--- a/InvenTree/common/migrations/0015_merge_0012_notificationentry_0014_auto_20210912_1804.py
+++ /dev/null
@@ -1,14 +0,0 @@
-# Generated by Django 3.2.5 on 2021-11-04 09:27
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('common', '0012_notificationentry'),
-        ('common', '0014_auto_20210912_1804'),
-    ]
-
-    operations = [
-    ]

From 6563c340ddf738d37c9da94bcb7365d418d61569 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 22:50:42 +0100
Subject: [PATCH 368/493] remove url

---
 InvenTree/InvenTree/urls.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 2e28ddf077..e74f34c956 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -70,9 +70,6 @@ apipatterns = [
     # Plugin endpoints
     url(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
 
-    # Webhook enpoint
-    path('', include(common_api_urls)),
-
     # InvenTree information endpoint
     url(r'^$', InfoView.as_view(), name='api-inventree-info'),
 

From 03f343a368351d67de8ccb472cd13ff2b03b95ce Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 22:50:55 +0100
Subject: [PATCH 369/493] remve tests

---
 InvenTree/common/tests.py | 121 ++------------------------------------
 1 file changed, 4 insertions(+), 117 deletions(-)

diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 0ec1812774..c20dc5d126 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -1,14 +1,13 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
-from http import HTTPStatus
-import json
+
 from datetime import timedelta
 
-from django.test import TestCase, Client
+from django.test import TestCase
 from django.contrib.auth import get_user_model
 
-from .models import InvenTreeSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
-from .api import WebhookView
+from .models import InvenTreeSetting
+from .models import NotificationEntry
 
 
 class SettingsTest(TestCase):
@@ -91,118 +90,6 @@ class SettingsTest(TestCase):
                     raise ValueError(f'Non-boolean default value specified for {key}')
 
 
-class WebhookMessageTests(TestCase):
-    def setUp(self):
-        self.endpoint_def = WebhookEndpoint.objects.create()
-        self.url = f'/api/webhook/{self.endpoint_def.endpoint_id}/'
-        self.client = Client(enforce_csrf_checks=True)
-
-    def test_bad_method(self):
-        response = self.client.get(self.url)
-
-        assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
-
-    def test_missing_token(self):
-        response = self.client.post(
-            self.url,
-            content_type='application/json',
-        )
-
-        assert response.status_code == HTTPStatus.FORBIDDEN
-        assert (
-            json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR
-        )
-
-    def test_bad_token(self):
-        response = self.client.post(
-            self.url,
-            content_type='application/json',
-            **{'HTTP_TOKEN': '1234567fghj'},
-        )
-
-        assert response.status_code == HTTPStatus.FORBIDDEN
-        assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
-
-    def test_bad_url(self):
-        response = self.client.post(
-            '/api/webhook/1234/',
-            content_type='application/json',
-        )
-
-        assert response.status_code == HTTPStatus.NOT_FOUND
-
-    def test_bad_json(self):
-        response = self.client.post(
-            self.url,
-            data="{'this': 123}",
-            content_type='application/json',
-            **{'HTTP_TOKEN': str(self.endpoint_def.token)},
-        )
-
-        assert response.status_code == HTTPStatus.NOT_ACCEPTABLE
-        assert (
-            json.loads(response.content)['detail'] == 'Expecting property name enclosed in double quotes'
-        )
-
-    def test_success_no_token_check(self):
-        # delete token
-        self.endpoint_def.token = ''
-        self.endpoint_def.save()
-
-        # check
-        response = self.client.post(
-            self.url,
-            content_type='application/json',
-        )
-
-        assert response.status_code == HTTPStatus.OK
-        assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
-
-    def test_bad_hmac(self):
-        # delete token
-        self.endpoint_def.token = ''
-        self.endpoint_def.secret = '123abc'
-        self.endpoint_def.save()
-
-        # check
-        response = self.client.post(
-            self.url,
-            content_type='application/json',
-        )
-
-        assert response.status_code == HTTPStatus.FORBIDDEN
-        assert (json.loads(response.content)['detail'] == WebhookView.model_class.MESSAGE_TOKEN_ERROR)
-
-    def test_success_hmac(self):
-        # delete token
-        self.endpoint_def.token = ''
-        self.endpoint_def.secret = '123abc'
-        self.endpoint_def.save()
-
-        # check
-        response = self.client.post(
-            self.url,
-            content_type='application/json',
-            **{'HTTP_TOKEN': str('68MXtc/OiXdA5e2Nq9hATEVrZFpLb3Zb0oau7n8s31I=')},
-        )
-
-        assert response.status_code == HTTPStatus.OK
-        assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
-
-    def test_success(self):
-        response = self.client.post(
-            self.url,
-            data={"this": "is a message"},
-            content_type='application/json',
-            **{'HTTP_TOKEN': str(self.endpoint_def.token)},
-        )
-
-        assert response.status_code == HTTPStatus.OK
-        assert str(response.content, 'utf-8') == WebhookView.model_class.MESSAGE_OK
-        message = WebhookMessage.objects.get()
-        assert message.body == {"this": "is a message"}
-
-
 class NotificationTest(TestCase):
 
     def test_check_notification_entries(self):

From d57fc5392b6cbea286d187bbe846daf16e1caa4d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 22:52:29 +0100
Subject: [PATCH 370/493] remove model

---
 InvenTree/common/models.py | 186 -------------------------------------
 1 file changed, 186 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 50b7beac13..3ab6413d8c 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -9,12 +9,6 @@ from __future__ import unicode_literals
 import os
 import decimal
 import math
-import uuid
-import hmac
-import json
-import hashlib
-import base64
-from secrets import compare_digest
 from datetime import datetime, timedelta
 
 from django.db import models, transaction
@@ -26,8 +20,6 @@ from djmoney.settings import CURRENCY_CHOICES
 from djmoney.contrib.exchange.models import convert_money
 from djmoney.contrib.exchange.exceptions import MissingRate
 
-from rest_framework.exceptions import PermissionDenied
-
 from django.utils.translation import ugettext_lazy as _
 from django.core.validators import MinValueValidator, URLValidator
 from django.core.exceptions import ValidationError
@@ -1397,184 +1389,6 @@ class ColorTheme(models.Model):
         return False
 
 
-class VerificationMethod:
-    NONE = 0
-    TOKEN = 1
-    HMAC = 2
-
-
-class WebhookEndpoint(models.Model):
-    """ Defines a Webhook entdpoint
-
-    Attributes:
-        endpoint_id: Path to the webhook,
-        name: Name of the webhook,
-        active: Is this webhook active?,
-        user: User associated with webhook,
-        token: Token for sending a webhook,
-        secret: Shared secret for HMAC verification,
-    """
-
-    # Token
-    TOKEN_NAME = "Token"
-    VERIFICATION_METHOD = VerificationMethod.NONE
-
-    MESSAGE_OK = "Message was received."
-    MESSAGE_TOKEN_ERROR = "Incorrect token in header."
-
-    endpoint_id = models.CharField(
-        max_length=255,
-        verbose_name=_('Endpoint'),
-        help_text=_('Endpoint at which this webhook is received'),
-        default=uuid.uuid4,
-        editable=False,
-    )
-
-    name = models.CharField(
-        max_length=255,
-        blank=True, null=True,
-        verbose_name=_('Name'),
-        help_text=_('Name for this webhook')
-    )
-
-    active = models.BooleanField(
-        default=True,
-        verbose_name=_('Active'),
-        help_text=_('Is this webhook active')
-    )
-
-    user = models.ForeignKey(
-        User,
-        on_delete=models.SET_NULL,
-        blank=True, null=True,
-        verbose_name=_('User'),
-        help_text=_('User'),
-    )
-
-    token = models.CharField(
-        max_length=255,
-        blank=True, null=True,
-        verbose_name=_('Token'),
-        help_text=_('Token for access'),
-        default=uuid.uuid4,
-    )
-
-    secret = models.CharField(
-        max_length=255,
-        blank=True, null=True,
-        verbose_name=_('Secret'),
-        help_text=_('Shared secret for HMAC'),
-    )
-
-    # To be overridden
-
-    def init(self, request, *args, **kwargs):
-        self.verify = self.VERIFICATION_METHOD
-
-    def process_webhook(self):
-        if self.token:
-            self.token = self.token
-            self.verify = VerificationMethod.TOKEN
-            # TODO make a object-setting
-        if self.secret:
-            self.secret = self.secret
-            self.verify = VerificationMethod.HMAC
-            # TODO make a object-setting
-        return True
-
-    def validate_token(self, payload, headers, request):
-        token = headers.get(self.TOKEN_NAME, "")
-
-        # no token
-        if self.verify == VerificationMethod.NONE:
-            pass
-
-        # static token
-        elif self.verify == VerificationMethod.TOKEN:
-            if not compare_digest(token, self.token):
-                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
-
-        # hmac token
-        elif self.verify == VerificationMethod.HMAC:
-            digest = hmac.new(self.secret.encode('utf-8'), request.body, hashlib.sha256).digest()
-            computed_hmac = base64.b64encode(digest)
-            if not hmac.compare_digest(computed_hmac, token.encode('utf-8')):
-                raise PermissionDenied(self.MESSAGE_TOKEN_ERROR)
-
-        return True
-
-    def save_data(self, payload, headers=None, request=None):
-        return WebhookMessage.objects.create(
-            host=request.get_host(),
-            header=json.dumps({key: val for key, val in headers.items()}),
-            body=payload,
-            endpoint=self,
-        )
-
-    def process_payload(self, message, payload=None, headers=None):
-        return True
-
-    def get_return(self, payload, headers=None, request=None):
-        return self.MESSAGE_OK
-
-
-class WebhookMessage(models.Model):
-    """ Defines a webhook message
-
-    Attributes:
-        message_id: Unique identifier for this message,
-        host: Host from which this message was received,
-        header: Header of this message,
-        body: Body of this message,
-        endpoint: Endpoint on which this message was received,
-        worked_on: Was the work on this message finished?
-    """
-
-    message_id = models.UUIDField(
-        verbose_name=_('Message ID'),
-        help_text=_('Unique identifier for this message'),
-        primary_key=True,
-        default=uuid.uuid4,
-        editable=False,
-    )
-
-    host = models.CharField(
-        max_length=255,
-        verbose_name=_('Host'),
-        help_text=_('Host from which this message was received'),
-        editable=False,
-    )
-
-    header = models.CharField(
-        max_length=255,
-        blank=True, null=True,
-        verbose_name=_('Header'),
-        help_text=_('Header of this message'),
-        editable=False,
-    )
-
-    body = models.JSONField(
-        blank=True, null=True,
-        verbose_name=_('Body'),
-        help_text=_('Body of this message'),
-        editable=False,
-    )
-
-    endpoint = models.ForeignKey(
-        WebhookEndpoint,
-        on_delete=models.SET_NULL,
-        blank=True, null=True,
-        verbose_name=_('Endpoint'),
-        help_text=_('Endpoint on which this message was received'),
-    )
-
-    worked_on = models.BooleanField(
-        default=False,
-        verbose_name=_('Worked on'),
-        help_text=_('Was the work on this message finished?'),
-    )
-
-
 class NotificationEntry(models.Model):
     """
     A NotificationEntry records the last time a particular notifaction was sent out.

From d4939e058c8fd664ee4058d5784dcac81232090e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 22:52:42 +0100
Subject: [PATCH 371/493] remove admin

---
 InvenTree/common/admin.py | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/InvenTree/common/admin.py b/InvenTree/common/admin.py
index 3ec0e32da1..c5950a0c0a 100644
--- a/InvenTree/common/admin.py
+++ b/InvenTree/common/admin.py
@@ -38,11 +38,6 @@ class UserSettingsAdmin(ImportExportModelAdmin):
             return []
 
 
-class WebhookAdmin(ImportExportModelAdmin):
-
-    list_display = ('endpoint_id', 'name', 'active', 'user')
-
-
 class NotificationEntryAdmin(admin.ModelAdmin):
 
     list_display = ('key', 'uid', 'updated', )
@@ -50,6 +45,4 @@ class NotificationEntryAdmin(admin.ModelAdmin):
 
 admin.site.register(common.models.InvenTreeSetting, SettingsAdmin)
 admin.site.register(common.models.InvenTreeUserSetting, UserSettingsAdmin)
-admin.site.register(common.models.WebhookEndpoint, WebhookAdmin)
-admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin)
 admin.site.register(common.models.NotificationEntry, NotificationEntryAdmin)

From 4baf2971da0ebadbf3894a364cc02e3bc694fd70 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 22:53:22 +0100
Subject: [PATCH 372/493] remove webhook apis

---
 InvenTree/common/api.py | 91 +----------------------------------------
 1 file changed, 1 insertion(+), 90 deletions(-)

diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py
index f71bf5b958..6dd51bdff1 100644
--- a/InvenTree/common/api.py
+++ b/InvenTree/common/api.py
@@ -5,101 +5,13 @@ Provides a JSON API for common components.
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import json
-
-from django.http.response import HttpResponse
-from django.utils.decorators import method_decorator
-from django.urls import path
-from django.views.decorators.csrf import csrf_exempt
 from django.conf.urls import url, include
 
-from rest_framework.views import APIView
-from rest_framework.exceptions import NotAcceptable, NotFound
 from django_filters.rest_framework import DjangoFilterBackend
 from rest_framework import filters, generics, permissions
-from django_q.tasks import async_task
 
 import common.models
 import common.serializers
-from InvenTree.helpers import inheritors
-
-
-class CsrfExemptMixin(object):
-    """
-    Exempts the view from CSRF requirements.
-    """
-
-    @method_decorator(csrf_exempt)
-    def dispatch(self, *args, **kwargs):
-        return super(CsrfExemptMixin, self).dispatch(*args, **kwargs)
-
-
-class WebhookView(CsrfExemptMixin, APIView):
-    """
-    Endpoint for receiving webhooks.
-    """
-    authentication_classes = []
-    permission_classes = []
-    model_class = common.models.WebhookEndpoint
-    run_async = False
-
-    def post(self, request, endpoint, *args, **kwargs):
-        # get webhook definition
-        self._get_webhook(endpoint, request, *args, **kwargs)
-
-        # check headers
-        headers = request.headers
-        try:
-            payload = json.loads(request.body)
-        except json.decoder.JSONDecodeError as error:
-            raise NotAcceptable(error.msg)
-
-        # validate
-        self.webhook.validate_token(payload, headers, request)
-        # process data
-        message = self.webhook.save_data(payload, headers, request)
-        if self.run_async:
-            async_task(self._process_payload, message.id)
-        else:
-            self._process_result(
-                self.webhook.process_payload(message, payload, headers),
-                message,
-            )
-
-        # return results
-        data = self.webhook.get_return(payload, headers, request)
-        return HttpResponse(data)
-
-    def _process_payload(self, message_id):
-        message = common.models.WebhookMessage.objects.get(message_id=message_id)
-        self._process_result(
-            self.webhook.process_payload(message, message.body, message.header),
-            message,
-        )
-
-    def _process_result(self, result, message):
-        if result:
-            message.worked_on = result
-            message.save()
-        else:
-            message.delete()
-
-    def _escalate_object(self, obj):
-        classes = inheritors(obj.__class__)
-        for cls in classes:
-            mdl_name = cls._meta.model_name
-            if hasattr(obj, mdl_name):
-                return getattr(obj, mdl_name)
-        return obj
-
-    def _get_webhook(self, endpoint, request, *args, **kwargs):
-        try:
-            webhook = self.model_class.objects.get(endpoint_id=endpoint)
-            self.webhook = self._escalate_object(webhook)
-            self.webhook.init(request, *args, **kwargs)
-            return self.webhook.process_webhook()
-        except self.model_class.DoesNotExist:
-            raise NotFound()
 
 
 class SettingsList(generics.ListAPIView):
@@ -219,7 +131,6 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
 
 
 common_api_urls = [
-    path('webhook/<slug:endpoint>/', WebhookView.as_view(), name='api-webhook'),
 
     # User settings
     url(r'^user/', include([
@@ -237,6 +148,6 @@ common_api_urls = [
 
         # Global Settings List
         url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'),
-    ])),
+    ]))
 
 ]

From 91bc8658871841bcf9913f7cfdb45ecdb634831f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 23:19:02 +0100
Subject: [PATCH 373/493] remove webhook ruleset

---
 InvenTree/users/models.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index ac04f788a9..a1130321e8 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -152,8 +152,6 @@ class RuleSet(models.Model):
         'common_colortheme',
         'common_inventreesetting',
         'common_inventreeusersetting',
-        'common_webhookendpoint',
-        'common_webhookmessage',
         'common_notificationentry',
         'company_contact',
         'users_owner',

From ce71508d8dcfe50ea6ddfb0b22620d1d9bdfe991 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 19 Nov 2021 23:54:42 +0100
Subject: [PATCH 374/493] remove helper for webhooks

---
 InvenTree/InvenTree/helpers.py | 15 ---------------
 1 file changed, 15 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 6ae31d6f5f..fd86306627 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -696,18 +696,3 @@ def clean_decimal(number):
         return Decimal(0)
 
     return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
-
-
-def inheritors(cls):
-    """
-    Return all classes that are subclasses from the supplied cls
-    """
-    subcls = set()
-    work = [cls]
-    while work:
-        parent = work.pop()
-        for child in parent.__subclasses__():
-            if child not in subcls:
-                subcls.add(child)
-                work.append(child)
-    return subcls

From ad98c1df483fb4647fc77278074f930ab3320852 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:03:57 +0100
Subject: [PATCH 375/493] refactor registry cleaning

---
 InvenTree/plugin/apps.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 14f077847b..cd16de5291 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -68,10 +68,7 @@ class PluginAppConfig(AppConfig):
             set_maintenance_mode(True)
 
         # remove all plugins from registry
-        # plugins = settings.INTEGRATION_PLUGINS
-        settings.INTEGRATION_PLUGINS = {}
-        # plugins_inactive = settings.INTEGRATION_PLUGINS_INACTIVE
-        settings.INTEGRATION_PLUGINS_INACTIVE = {}
+        self._clean_registry()
 
         # deactivate all integrations
         self._deactivate_plugins()
@@ -315,6 +312,11 @@ class PluginAppConfig(AppConfig):
         # update urls to remove the apps from the site admin
         self._update_urls()
 
+    def _clean_registry(self):
+        # remove all plugins from registry
+        settings.INTEGRATION_PLUGINS = {}
+        settings.INTEGRATION_PLUGINS_INACTIVE = {}
+
     def _update_urls(self):
         from InvenTree.urls import urlpatterns
         from plugin.urls import PLUGIN_BASE, get_integration_urls

From c57393f45754b78e12901ed89536d8bb4ad62a3c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:05:01 +0100
Subject: [PATCH 376/493] refactor clean installed apps

---
 InvenTree/plugin/apps.py | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index cd16de5291..6abb67a446 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -300,18 +300,22 @@ class PluginAppConfig(AppConfig):
                 apps.all_models.pop(app_name)
 
         # remove plugin from installed_apps
-        for plugin in settings.INTEGRATION_APPS_PATHS:
-            if plugin in settings.INSTALLED_APPS:
-                settings.INSTALLED_APPS.remove(plugin)
+        self._clean_installed_apps()
 
         # reset load flag and reload apps
-        settings.INTEGRATION_APPS_PATHS = []
         settings.INTEGRATION_APPS_LOADED = False
         self._reload_apps()
 
         # update urls to remove the apps from the site admin
         self._update_urls()
 
+    def _clean_installed_apps(self):
+        for plugin in settings.INTEGRATION_APPS_PATHS:
+            if plugin in settings.INSTALLED_APPS:
+                settings.INSTALLED_APPS.remove(plugin)
+
+        settings.INTEGRATION_APPS_PATHS = []
+
     def _clean_registry(self):
         # remove all plugins from registry
         settings.INTEGRATION_PLUGINS = {}

From 0b6e9ef4c9ef029dd36251e5796dad04b35365f1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:05:36 +0100
Subject: [PATCH 377/493] custom error

---
 InvenTree/plugin/apps.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 6abb67a446..ce0b07e5fd 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -29,6 +29,15 @@ from plugin.integration import IntegrationPluginBase
 logger = logging.getLogger('inventree')
 
 
+class PluginLoadingError(Exception):
+    def __init__(self, path, message):
+        self.path = path
+        self.message = message
+    
+    def __str__(self):
+        return self.message
+
+
 class PluginAppConfig(AppConfig):
     name = 'plugin'
 

From e301971159c31fcdb7285ade3943f67c1d15383a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:10:47 +0100
Subject: [PATCH 378/493] keep reloading save - wrap reloading - throw custom
 error - log custom error in loading function

---
 InvenTree/plugin/apps.py | 23 ++++++++++++++++++++---
 1 file changed, 20 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index ce0b07e5fd..774a62475e 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -3,6 +3,8 @@ from __future__ import unicode_literals
 import importlib
 import pathlib
 import logging
+import sysconfig
+import traceback
 from typing import OrderedDict
 from importlib import reload
 
@@ -62,6 +64,8 @@ class PluginAppConfig(AppConfig):
         except (OperationalError, ProgrammingError):
             # Exception if the database has not been migrated yet
             logger.info('Database not accessible while loading plugins')
+        except PluginLoadingError as error:
+            logger.error(f'Encountered an error with {error.path}:\n{error.message}')
 
         # remove maintenance
         if not _maintenance:
@@ -348,10 +352,23 @@ class PluginAppConfig(AppConfig):
             apps.app_configs = OrderedDict()
             apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
             apps.clear_cache()
-            apps.populate(settings.INSTALLED_APPS)
-            return
+            self._try_reload(apps.populate, settings.INSTALLED_APPS)
         settings.INTEGRATION_PLUGINS_RELOADING = True
-        apps.set_installed_apps(settings.INSTALLED_APPS)
+        self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
         settings.INTEGRATION_PLUGINS_RELOADING = False
+
+    def _try_reload(self, cmd, *args, **kwargs):
+        """
+        wrapper to try reloading the apps
+        throws an custom error that gets handled by the loading function
+        """
+        try:
+            cmd(*args, **kwargs)
+            return True, []
+        except Exception as error:
+            package_path = traceback.extract_tb(error.__traceback__)[-1].filename
+            install_path = sysconfig.get_paths()["purelib"]
+            package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
+            raise PluginLoadingError(package_name, str(error))
     # endregion
     # endregion

From be24d141de3b3188ba8abc8632100917fa9c5313 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:16:03 +0100
Subject: [PATCH 379/493] reload without integration apps if loading fails

---
 InvenTree/plugin/apps.py | 69 ++++++++++++++++++++++++++++++----------
 1 file changed, 52 insertions(+), 17 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 774a62475e..8c8e918613 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -57,15 +57,20 @@ class PluginAppConfig(AppConfig):
         if not _maintenance:
             set_maintenance_mode(True)
 
-        try:
-            # we are using the db so for migrations etc we need to try this block
-            self._init_plugins()
-            self._activate_plugins()
-        except (OperationalError, ProgrammingError):
-            # Exception if the database has not been migrated yet
-            logger.info('Database not accessible while loading plugins')
-        except PluginLoadingError as error:
-            logger.error(f'Encountered an error with {error.path}:\n{error.message}')
+        registered_sucessfull = False
+        blocked_plugin = None
+        while not registered_sucessfull:
+            try:
+                # 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
+            except (OperationalError, ProgrammingError):
+                # Exception if the database has not been migrated yet
+                logger.info('Database not accessible while loading plugins')
+            except PluginLoadingError as error:
+                logger.error(f'Encountered an error with {error.path}:\n{error.message}')
+                blocked_plugin = error.path
 
         # remove maintenance
         if not _maintenance:
@@ -121,8 +126,13 @@ class PluginAppConfig(AppConfig):
         logger.info(f'Found {len(settings.PLUGINS)} plugins!')
         logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
 
-    def _init_plugins(self):
-        """initialise all found plugins"""
+    def _init_plugins(self, disabled=None):
+        """initialise all found plugins
+
+        :param disabled: loading path of disabled app, defaults to None
+        :type disabled: str, optional
+        :raises error: PluginLoadingError
+        """
         from plugin.models import PluginConfig
 
         logger.info('Starting plugin initialisation')
@@ -146,6 +156,20 @@ class PluginAppConfig(AppConfig):
 
             # 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
+                if disabled:
+                    if plugin.__name__==disabled:
+                        # errors are bad so disable the plugin in the database
+                        # but only if not in testing mode as that breaks in the GH pipeline
+                        if not settings.PLUGIN_TESTING:
+                            plugin_db_setting.active = False
+                            # TODO save the error to the plugin
+                            plugin_db_setting.save()
+
+                        # add to inactive plugins so it shows up in the ui
+                        settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+                        continue  # continue -> the plugin is not loaded
+
                 # init package
                 # now we can be sure that an admin has activated the plugin -> 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.
@@ -162,14 +186,18 @@ class PluginAppConfig(AppConfig):
                 # save for later reference
                 settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
 
-    def _activate_plugins(self):
-        """run integration functions for all plugins"""
+    def _activate_plugins(self, force_reload=False):
+        """run integration functions for all plugins
+
+        :param force_reload: force reload base apps, defaults to False
+        :type force_reload: bool, optional
+        """
         # activate integrations
         plugins = settings.INTEGRATION_PLUGINS.items()
         logger.info(f'Found {len(plugins)} active plugins')
 
         self.activate_integration_globalsettings(plugins)
-        self.activate_integration_app(plugins)
+        self.activate_integration_app(plugins, force_reload=force_reload)
 
     def _deactivate_plugins(self):
         """run integration deactivation functions for all plugins"""
@@ -209,7 +237,14 @@ class PluginAppConfig(AppConfig):
     # endregion
 
     # region integration_app
-    def activate_integration_app(self, plugins):
+    def activate_integration_app(self, plugins, force_reload=False):
+        """activate AppMixin plugins - add custom apps and reload
+
+        :param plugins: list of IntegrationPlugins that should be installed
+        :type plugins: dict
+        :param force_reload: only reload base apps, defaults to False
+        :type force_reload: bool, optional
+        """
         from common.models import InvenTreeSetting
 
         if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
@@ -225,9 +260,9 @@ class PluginAppConfig(AppConfig):
                         settings.INTEGRATION_APPS_PATHS += [plugin_path]
                         apps_changed = True
 
-            if apps_changed:
+            if apps_changed or force_reload:
                 # if apps were changed reload
-                if settings.INTEGRATION_APPS_LOADING:
+                if settings.INTEGRATION_APPS_LOADING or force_reload:
                     settings.INTEGRATION_APPS_LOADING = False
                     self._reload_apps(populate=True)
                 self._reload_apps()

From e70b9bd28fce7e728d52786cc10006a9a8c90b53 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:19:41 +0100
Subject: [PATCH 380/493] more docs

---
 InvenTree/plugin/apps.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 8c8e918613..2c7c957667 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -261,8 +261,9 @@ class PluginAppConfig(AppConfig):
                         apps_changed = True
 
             if apps_changed or force_reload:
-                # if apps were changed reload
+                # if apps were changed or force loading base apps -> reload
                 if settings.INTEGRATION_APPS_LOADING or force_reload:
+                    # first startup or force loading of base apps -> registry is prob false
                     settings.INTEGRATION_APPS_LOADING = False
                     self._reload_apps(populate=True)
                 self._reload_apps()
@@ -382,8 +383,7 @@ class PluginAppConfig(AppConfig):
                     urlpatterns[index] = url(f'^{PLUGIN_BASE}/', include((integ_urls, 'plugin')))
         clear_url_caches()
 
-    def _reload_apps(self, populate: bool = False):
-        if populate:
+            # we can not use the built in functions as we need to brute force the registry
             apps.app_configs = OrderedDict()
             apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
             apps.clear_cache()

From 3dfb8167a7fe1cf20bd96e852679411eff89f756 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:20:14 +0100
Subject: [PATCH 381/493] refactor

---
 InvenTree/plugin/apps.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 2c7c957667..dd9757cd97 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -265,7 +265,7 @@ class PluginAppConfig(AppConfig):
                 if settings.INTEGRATION_APPS_LOADING or force_reload:
                     # first startup or force loading of base apps -> registry is prob false
                     settings.INTEGRATION_APPS_LOADING = False
-                    self._reload_apps(populate=True)
+                    self._reload_apps(force_reload=True)
                 self._reload_apps()
                 # rediscover models/ admin sites
                 self._reregister_contrib_apps()
@@ -383,11 +383,14 @@ class PluginAppConfig(AppConfig):
                     urlpatterns[index] = url(f'^{PLUGIN_BASE}/', include((integ_urls, 'plugin')))
         clear_url_caches()
 
+    def _reload_apps(self, force_reload: bool = False):
+        if force_reload:
             # we can not use the built in functions as we need to brute force the registry
             apps.app_configs = OrderedDict()
             apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
             apps.clear_cache()
             self._try_reload(apps.populate, settings.INSTALLED_APPS)
+
         settings.INTEGRATION_PLUGINS_RELOADING = True
         self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
         settings.INTEGRATION_PLUGINS_RELOADING = False

From 4c7d295c0e446653cfb4baad5b11933cb140d1e1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:20:55 +0100
Subject: [PATCH 382/493] hard reset all plugin registration mechanisms on
 error

---
 InvenTree/plugin/apps.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index dd9757cd97..657c2a1353 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -72,6 +72,11 @@ class PluginAppConfig(AppConfig):
                 logger.error(f'Encountered an error with {error.path}:\n{error.message}')
                 blocked_plugin = error.path
 
+                # init apps without any integration plugins
+                self._clean_registry()
+                self._clean_installed_apps()
+                self._activate_plugins(force_reload=True)
+
         # remove maintenance
         if not _maintenance:
             set_maintenance_mode(False)

From d36ab0d9cda015399509c6a987699e0247f4aaea Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:22:25 +0100
Subject: [PATCH 383/493] some more docs

---
 InvenTree/plugin/apps.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 657c2a1353..c79909e25e 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -70,13 +70,15 @@ class PluginAppConfig(AppConfig):
                 logger.info('Database not accessible while loading plugins')
             except PluginLoadingError as error:
                 logger.error(f'Encountered an error with {error.path}:\n{error.message}')
-                blocked_plugin = error.path
+                blocked_plugin = error.path  # we will not try to load this app again
 
                 # init apps without any integration plugins
                 self._clean_registry()
                 self._clean_installed_apps()
                 self._activate_plugins(force_reload=True)
 
+                # now the loading will re-start up with init
+
         # remove maintenance
         if not _maintenance:
             set_maintenance_mode(False)

From 8bcdad6a8fc7a06804aad9811b269988964517e4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:29:32 +0100
Subject: [PATCH 384/493] make startup more failsafe

---
 InvenTree/InvenTree/settings.py |  1 +
 InvenTree/plugin/plugins.py     | 11 +++++++++++
 2 files changed, 12 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 20291fe59f..732b4a5131 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -889,6 +889,7 @@ INTEGRATION_PLUGIN_GLOBALSETTING = {}
 INTEGRATION_APPS_LOADING = True     # Marks if apps were reloaded yet
 INTEGRATION_PLUGINS_RELOADING = False
 INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
+INTEGRATION_STARTUP_ERRORS = {}     # Holds discovering errors
 
 # Test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 0822e412f3..1732dd641c 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -41,6 +41,17 @@ def get_modules(pkg, recursive: bool = False):
             context[name] = module
         except AppRegistryNotReady:
             pass
+        except Exception as error:
+            # this 'protects' against malformed plugin modules by more or less silently failing
+            # TODO log
+
+            # make sure the registry is set up
+            if 'discovery' not in settings.INTEGRATION_STARTUP_ERRORS:
+                settings.INTEGRATION_STARTUP_ERRORS['discovery'] = []
+
+            # add error to stack
+            settings.INTEGRATION_STARTUP_ERRORS['discovery'].append({name: str(error)})
+
     return [v for k, v in context.items()]
 
 

From ebe712312cf5625381e8f875b2cc6a91ee8838d6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:31:58 +0100
Subject: [PATCH 385/493] refactor

---
 InvenTree/InvenTree/settings.py | 2 +-
 InvenTree/plugin/plugins.py     | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 732b4a5131..6027e60bb2 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -889,7 +889,7 @@ INTEGRATION_PLUGIN_GLOBALSETTING = {}
 INTEGRATION_APPS_LOADING = True     # Marks if apps were reloaded yet
 INTEGRATION_PLUGINS_RELOADING = False
 INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
-INTEGRATION_STARTUP_ERRORS = {}     # Holds discovering errors
+INTEGRATION_ERRORS = {}              # Holds discovering errors
 
 # Test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 1732dd641c..7271ab8752 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -46,11 +46,11 @@ def get_modules(pkg, recursive: bool = False):
             # TODO log
 
             # make sure the registry is set up
-            if 'discovery' not in settings.INTEGRATION_STARTUP_ERRORS:
-                settings.INTEGRATION_STARTUP_ERRORS['discovery'] = []
+            if 'discovery' not in settings.INTEGRATION_ERRORS:
+                settings.INTEGRATION_ERRORS['discovery'] = []
 
             # add error to stack
-            settings.INTEGRATION_STARTUP_ERRORS['discovery'].append({name: str(error)})
+            settings.INTEGRATION_ERRORS['discovery'].append({name: str(error)})
 
     return [v for k, v in context.items()]
 

From 9087cabe5f78b15f8fb4252bdb534468f8e658bd Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:35:43 +0100
Subject: [PATCH 386/493] refactor integration error logging into helper

---
 InvenTree/InvenTree/helpers.py |  9 +++++++++
 InvenTree/plugin/plugins.py    | 12 +++++-------
 2 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index fd86306627..20680540e6 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -14,6 +14,7 @@ from wsgiref.util import FileWrapper
 from django.http import StreamingHttpResponse
 from django.core.exceptions import ValidationError, FieldError
 from django.utils.translation import ugettext_lazy as _
+from django.conf import settings
 
 from django.contrib.auth.models import Permission
 
@@ -696,3 +697,11 @@ def clean_decimal(number):
         return Decimal(0)
 
     return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
+
+def log_plugin_error(error, reference: str = 'general'):
+    # make sure the registry is set up
+    if reference not in settings.INTEGRATION_ERRORS:
+        settings.INTEGRATION_ERRORS[reference] = []
+
+    # add error to stack
+    settings.INTEGRATION_ERRORS[reference].append(error)
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 7271ab8752..d215faacdd 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -9,6 +9,8 @@ import logging
 from django.conf import settings
 from django.core.exceptions import AppRegistryNotReady
 
+from InvenTree.helpers import log_plugin_error
+
 # Action plugins
 import plugin.builtin.action as action
 from plugin.action import ActionPlugin
@@ -43,14 +45,10 @@ def get_modules(pkg, recursive: bool = False):
             pass
         except Exception as error:
             # this 'protects' against malformed plugin modules by more or less silently failing
-            # TODO log
+            # TODO log to logging
 
-            # make sure the registry is set up
-            if 'discovery' not in settings.INTEGRATION_ERRORS:
-                settings.INTEGRATION_ERRORS['discovery'] = []
-
-            # add error to stack
-            settings.INTEGRATION_ERRORS['discovery'].append({name: str(error)})
+            # log to stack
+            log_plugin_error({name: str(error)}, 'discovery')
 
     return [v for k, v in context.items()]
 

From 4b3d5b27a6fd2dbad7fabe6df96672be2a266bb0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:39:47 +0100
Subject: [PATCH 387/493] add more stack logging points

---
 InvenTree/plugin/apps.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index c79909e25e..66c2428664 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -24,6 +24,8 @@ except:
 from maintenance_mode.core import maintenance_mode_on
 from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
 
+from InvenTree.helpers import log_plugin_error
+
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
 
@@ -70,6 +72,7 @@ class PluginAppConfig(AppConfig):
                 logger.info('Database not accessible while loading plugins')
             except PluginLoadingError as error:
                 logger.error(f'Encountered an error with {error.path}:\n{error.message}')
+                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
@@ -171,6 +174,8 @@ class PluginAppConfig(AppConfig):
                         if not settings.PLUGIN_TESTING:
                             plugin_db_setting.active = False
                             # TODO save the error to the plugin
+
+                            log_plugin_error({plug_key: 'Disabled'}, 'init')
                             plugin_db_setting.save()
 
                         # add to inactive plugins so it shows up in the ui

From 9f0882d6371489943624dec5973203e487ef91a3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 03:52:27 +0100
Subject: [PATCH 388/493] move imports

---
 InvenTree/plugin/apps.py    | 5 +++--
 InvenTree/plugin/plugins.py | 4 ++--
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 66c2428664..33b70cb9fc 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -24,8 +24,6 @@ except:
 from maintenance_mode.core import maintenance_mode_on
 from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
 
-from InvenTree.helpers import log_plugin_error
-
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
 
@@ -53,6 +51,8 @@ class PluginAppConfig(AppConfig):
     # region public plugin functions
     def load_plugins(self):
         """load and activate all IntegrationPlugins"""
+        from InvenTree.helpers import log_plugin_error
+
         logger.info('Start loading plugins')
         # set maintanace mode
         _maintenance = bool(get_maintenance_mode())
@@ -143,6 +143,7 @@ class PluginAppConfig(AppConfig):
         :type disabled: str, optional
         :raises error: PluginLoadingError
         """
+        from InvenTree.helpers import log_plugin_error
         from plugin.models import PluginConfig
 
         logger.info('Starting plugin initialisation')
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index d215faacdd..a8b08cf6b4 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -9,8 +9,6 @@ import logging
 from django.conf import settings
 from django.core.exceptions import AppRegistryNotReady
 
-from InvenTree.helpers import log_plugin_error
-
 # Action plugins
 import plugin.builtin.action as action
 from plugin.action import ActionPlugin
@@ -29,6 +27,8 @@ def iter_namespace(pkg):
 
 def get_modules(pkg, recursive: bool = False):
     """get all modules in a package"""
+    from InvenTree.helpers import log_plugin_error
+
     if not recursive:
         return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
 

From 0d44a4cfa5cb9b4a34c7c1e7541809ca75145715 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 04:00:34 +0100
Subject: [PATCH 389/493] error retrieving tag

---
 .../plugin/templatetags/plugin_extras.py      |  5 ++++
 .../templates/InvenTree/settings/plugin.html  | 28 +++++++++++++++++++
 2 files changed, 33 insertions(+)

diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 05ba26d09d..e18ef70b3c 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -51,3 +51,8 @@ def safe_url(view_name, *args, **kwargs):
         return reverse(view_name, args=args, kwargs=kwargs)
     except:
         return None
+
+@register.simple_tag()
+def plugin_errors(*args, **kwargs):
+    """Return all plugin errors"""
+    return djangosettings.INTEGRATION_ERRORS
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 920d0b7804..e5bf97a512 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -102,4 +102,32 @@
 </table>
 </div>
 
+
+<h4>{% trans "Plugin errors" %}</h4>
+<div class='table-responsive'>
+    <table class='table table-striped table-condensed'>
+        <thead>
+            <tr>
+                <th>{% trans "Stage" %}</th>
+                <th>{% trans "Name" %}</th>
+                <th>{% trans "Message" %}</th>
+            </tr>
+        </thead>
+        
+        <tbody>
+        {% plugin_errors as pl_errors %}
+        {% for stage, errors in pl_errors.items %}
+            {% for error_detail in errors %}
+            {% for name, message in error_detail.items %}
+            <tr>
+                <td>{{ stage }}</td>
+                <td>{{ name }}</td>
+                <td>{{ message }}</td>
+            </tr>
+            {% endfor %}
+            {% endfor %}
+        {% endfor %}
+        </tbody>
+    </table>
+    </div>
 {% endblock %}

From 12fbd92bada3e05f8cf964a583779c60c40726e5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 04:04:52 +0100
Subject: [PATCH 390/493] conditional error stack showing

---
 InvenTree/templates/InvenTree/settings/plugin.html | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index e5bf97a512..ef0888618b 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -103,7 +103,9 @@
 </div>
 
 
-<h4>{% trans "Plugin errors" %}</h4>
+{% plugin_errors as pl_errors %}
+{% if pl_errors %}
+<h4>{% trans "Plugin Error Stack" %}</h4>
 <div class='table-responsive'>
     <table class='table table-striped table-condensed'>
         <thead>
@@ -115,7 +117,6 @@
         </thead>
         
         <tbody>
-        {% plugin_errors as pl_errors %}
         {% for stage, errors in pl_errors.items %}
             {% for error_detail in errors %}
             {% for name, message in error_detail.items %}
@@ -130,4 +131,6 @@
         </tbody>
     </table>
     </div>
+{% endif %}
+
 {% endblock %}

From 6301f064162ab4adaab9a5b67b25cad8442a9c2a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 04:06:15 +0100
Subject: [PATCH 391/493] PEP fix

---
 InvenTree/InvenTree/helpers.py                 | 1 +
 InvenTree/plugin/apps.py                       | 2 +-
 InvenTree/plugin/templatetags/plugin_extras.py | 1 +
 3 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 20680540e6..829e845ce0 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -698,6 +698,7 @@ def clean_decimal(number):
 
     return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
 
+
 def log_plugin_error(error, reference: str = 'general'):
     # make sure the registry is set up
     if reference not in settings.INTEGRATION_ERRORS:
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 33b70cb9fc..1cfec7c2ed 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -169,7 +169,7 @@ class PluginAppConfig(AppConfig):
             if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
                 # check if the plugin was blocked -> threw an error
                 if disabled:
-                    if plugin.__name__==disabled:
+                    if plugin.__name__ == disabled:
                         # errors are bad so disable the plugin in the database
                         # but only if not in testing mode as that breaks in the GH pipeline
                         if not settings.PLUGIN_TESTING:
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index e18ef70b3c..480e6cd5d3 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -52,6 +52,7 @@ def safe_url(view_name, *args, **kwargs):
     except:
         return None
 
+
 @register.simple_tag()
 def plugin_errors(*args, **kwargs):
     """Return all plugin errors"""

From f667367a6b516695ea3dd9d41b9c182f8c372f56 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 04:08:21 +0100
Subject: [PATCH 392/493] add broken plugin for testing error stack

---
 InvenTree/plugin/samples/integration/broken_sample.py | 11 +++++++++++
 1 file changed, 11 insertions(+)
 create mode 100644 InvenTree/plugin/samples/integration/broken_sample.py

diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py
new file mode 100644
index 0000000000..17d2b1a08d
--- /dev/null
+++ b/InvenTree/plugin/samples/integration/broken_sample.py
@@ -0,0 +1,11 @@
+"""sample implementation for IntegrationPlugin"""
+from plugin.integration import IntegrationPluginBase
+
+aaa = bb
+
+class BrokenIntegrationPlugin(IntegrationPluginBase):
+    """
+    An very broken integration plugin
+    """
+
+    PLUGIN_NAME = "BrokenIntegrationPlugin"

From 98b0a2995ffd56417ad17f7b21e9551feedb78c4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 12:29:29 +0100
Subject: [PATCH 393/493] ignore error in borken sampel -> it should not work

---
 InvenTree/plugin/samples/integration/broken_sample.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py
index 17d2b1a08d..191e673176 100644
--- a/InvenTree/plugin/samples/integration/broken_sample.py
+++ b/InvenTree/plugin/samples/integration/broken_sample.py
@@ -1,7 +1,8 @@
 """sample implementation for IntegrationPlugin"""
 from plugin.integration import IntegrationPluginBase
 
-aaa = bb
+aaa = bb  # noqa: F821
+
 
 class BrokenIntegrationPlugin(IntegrationPluginBase):
     """

From e82c93ffaecdf573b399f80eeb64d5401cb268f0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 12:39:27 +0100
Subject: [PATCH 394/493] refactor into own helper function for plugins

---
 InvenTree/InvenTree/helpers.py |  9 ---------
 InvenTree/plugin/apps.py       |  4 ++--
 InvenTree/plugin/helpers.py    | 11 +++++++++++
 InvenTree/plugin/plugins.py    |  2 +-
 4 files changed, 14 insertions(+), 12 deletions(-)
 create mode 100644 InvenTree/plugin/helpers.py

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 829e845ce0..2b511bc09f 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -697,12 +697,3 @@ def clean_decimal(number):
         return Decimal(0)
 
     return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize()
-
-
-def log_plugin_error(error, reference: str = 'general'):
-    # make sure the registry is set up
-    if reference not in settings.INTEGRATION_ERRORS:
-        settings.INTEGRATION_ERRORS[reference] = []
-
-    # add error to stack
-    settings.INTEGRATION_ERRORS[reference].append(error)
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 1cfec7c2ed..b7ed20f365 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -51,7 +51,7 @@ class PluginAppConfig(AppConfig):
     # region public plugin functions
     def load_plugins(self):
         """load and activate all IntegrationPlugins"""
-        from InvenTree.helpers import log_plugin_error
+        from plugin.helpers import log_plugin_error
 
         logger.info('Start loading plugins')
         # set maintanace mode
@@ -143,7 +143,7 @@ class PluginAppConfig(AppConfig):
         :type disabled: str, optional
         :raises error: PluginLoadingError
         """
-        from InvenTree.helpers import log_plugin_error
+        from plugin.helpers import log_plugin_error
         from plugin.models import PluginConfig
 
         logger.info('Starting plugin initialisation')
diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
new file mode 100644
index 0000000000..1ebe146f41
--- /dev/null
+++ b/InvenTree/plugin/helpers.py
@@ -0,0 +1,11 @@
+"""Helpers for plugin app"""
+from django.conf import settings
+
+
+def log_plugin_error(error, reference: str = 'general'):
+    # make sure the registry is set up
+    if reference not in settings.INTEGRATION_ERRORS:
+        settings.INTEGRATION_ERRORS[reference] = []
+
+    # add error to stack
+    settings.INTEGRATION_ERRORS[reference].append(error)
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index a8b08cf6b4..66f841d95b 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -27,7 +27,7 @@ def iter_namespace(pkg):
 
 def get_modules(pkg, recursive: bool = False):
     """get all modules in a package"""
-    from InvenTree.helpers import log_plugin_error
+    from plugin.helpers import log_plugin_error
 
     if not recursive:
         return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]

From 4171fe42d9fcc3c82d37287d9c910f64fead388a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 12:47:34 +0100
Subject: [PATCH 395/493] docstring

---
 InvenTree/plugin/urls.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 558197cada..2a6c64b73c 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -8,6 +8,7 @@ PLUGIN_BASE = 'plugin'  # Constant for links
 
 
 def get_integration_urls():
+    """collect all plugin urls"""
     urls = []
     for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):

From 2f306d951f36c87c907eaf833ee0d19f98fcbd7a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 12:54:51 +0100
Subject: [PATCH 396/493] refactor url definition into plugin

---
 InvenTree/InvenTree/urls.py | 4 ++--
 InvenTree/plugin/apps.py    | 5 ++---
 InvenTree/plugin/urls.py    | 5 +++--
 3 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index e74f34c956..2a70ea6e14 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -18,7 +18,7 @@ from part.urls import part_urls
 from stock.urls import stock_urls
 from build.urls import build_urls
 from order.urls import order_urls
-from plugin.urls import plugin_urls, PLUGIN_BASE
+from plugin.urls import plugin_urls, get_integration_urls
 
 from barcodes.api import barcode_api_urls
 from common.api import common_api_urls
@@ -160,7 +160,7 @@ urlpatterns = [
 
     # plugin urls
     url(r'^plugins/', include(plugin_urls)),
-    url(f'^{PLUGIN_BASE}/', include(([], 'plugin'))),  # on startup we do not have any plugins enabled
+    get_integration_urls(),  # appends currently loaded plugin urls = None
 
     url(r'^markdownx/', include('markdownx.urls')),
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index b7ed20f365..c518e185f2 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -385,15 +385,14 @@ class PluginAppConfig(AppConfig):
 
     def _update_urls(self):
         from InvenTree.urls import urlpatterns
-        from plugin.urls import PLUGIN_BASE, get_integration_urls
+        from plugin.urls import get_integration_urls
 
         for index, a in enumerate(urlpatterns):
             if hasattr(a, 'app_name'):
                 if a.app_name == 'admin':
                     urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
                 elif a.app_name == 'plugin':
-                    integ_urls = get_integration_urls()
-                    urlpatterns[index] = url(f'^{PLUGIN_BASE}/', include((integ_urls, 'plugin')))
+                    urlpatterns[index] = get_integration_urls()
         clear_url_caches()
 
     def _reload_apps(self, force_reload: bool = False):
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 2a6c64b73c..76de305ebc 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -2,18 +2,19 @@
 URL lookup for plugin app
 """
 from django.conf import settings
+from django.conf.urls import url, include
 
 
 PLUGIN_BASE = 'plugin'  # Constant for links
 
 
 def get_integration_urls():
-    """collect all plugin urls"""
+    """returns a urlpattern that can be integrated into the global urls"""
     urls = []
     for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
-    return urls
+    return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
 
 
 plugin_urls = [

From b05381fcc87d94ace923c682b0fb85d6420c95a6 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 13:00:12 +0100
Subject: [PATCH 397/493] rename / cleanup

---
 InvenTree/InvenTree/urls.py | 5 ++---
 InvenTree/plugin/apps.py    | 4 ++--
 InvenTree/plugin/urls.py    | 6 +-----
 3 files changed, 5 insertions(+), 10 deletions(-)

diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 2a70ea6e14..a0900c3fd4 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -18,7 +18,7 @@ from part.urls import part_urls
 from stock.urls import stock_urls
 from build.urls import build_urls
 from order.urls import order_urls
-from plugin.urls import plugin_urls, get_integration_urls
+from plugin.urls import get_plugin_urls
 
 from barcodes.api import barcode_api_urls
 from common.api import common_api_urls
@@ -159,8 +159,7 @@ urlpatterns = [
     url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
 
     # plugin urls
-    url(r'^plugins/', include(plugin_urls)),
-    get_integration_urls(),  # appends currently loaded plugin urls = None
+    get_plugin_urls(),  # appends currently loaded plugin urls = None
 
     url(r'^markdownx/', include('markdownx.urls')),
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index c518e185f2..4f94a773ac 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -385,14 +385,14 @@ class PluginAppConfig(AppConfig):
 
     def _update_urls(self):
         from InvenTree.urls import urlpatterns
-        from plugin.urls import get_integration_urls
+        from plugin.urls import get_plugin_urls
 
         for index, a in enumerate(urlpatterns):
             if hasattr(a, 'app_name'):
                 if a.app_name == 'admin':
                     urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
                 elif a.app_name == 'plugin':
-                    urlpatterns[index] = get_integration_urls()
+                    urlpatterns[index] = get_plugin_urls()
         clear_url_caches()
 
     def _reload_apps(self, force_reload: bool = False):
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 76de305ebc..29c6ecb32e 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -8,14 +8,10 @@ from django.conf.urls import url, include
 PLUGIN_BASE = 'plugin'  # Constant for links
 
 
-def get_integration_urls():
+def get_plugin_urls():
     """returns a urlpattern that can be integrated into the global urls"""
     urls = []
     for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
     return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
-
-
-plugin_urls = [
-]

From 71e05d569bc611073d9eeb5d0edcf8b86dc0cf9b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 13:14:18 +0100
Subject: [PATCH 398/493] refactor plugin error processing definition

---
 InvenTree/plugin/apps.py    |  8 ++------
 InvenTree/plugin/helpers.py | 10 ++++++++++
 2 files changed, 12 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 4f94a773ac..a99acc5193 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -3,8 +3,6 @@ from __future__ import unicode_literals
 import importlib
 import pathlib
 import logging
-import sysconfig
-import traceback
 from typing import OrderedDict
 from importlib import reload
 
@@ -26,6 +24,7 @@ from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
 
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
+from plugin.helpers import get_plugin_error
 
 
 logger = logging.getLogger('inventree')
@@ -416,9 +415,6 @@ class PluginAppConfig(AppConfig):
             cmd(*args, **kwargs)
             return True, []
         except Exception as error:
-            package_path = traceback.extract_tb(error.__traceback__)[-1].filename
-            install_path = sysconfig.get_paths()["purelib"]
-            package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
-            raise PluginLoadingError(package_name, str(error))
+            raise PluginLoadingError(get_plugin_error(error))
     # endregion
     # endregion
diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 1ebe146f41..46c92bb039 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -1,4 +1,8 @@
 """Helpers for plugin app"""
+import pathlib
+import sysconfig
+import traceback
+
 from django.conf import settings
 
 
@@ -9,3 +13,9 @@ def log_plugin_error(error, reference: str = 'general'):
 
     # add error to stack
     settings.INTEGRATION_ERRORS[reference].append(error)
+
+def get_plugin_error(error):
+    package_path = traceback.extract_tb(error.__traceback__)[-1].filename
+    install_path = sysconfig.get_paths()["purelib"]
+    package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
+    return package_name, str(error)

From 008917fdef1a582ca822a0a66f1b2766b8e12769 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 13:20:08 +0100
Subject: [PATCH 399/493] refactor custom error raising

---
 InvenTree/plugin/apps.py    | 17 ++++-------------
 InvenTree/plugin/helpers.py | 14 +++++++++++++-
 2 files changed, 17 insertions(+), 14 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index a99acc5193..2d3d1d6181 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -24,21 +24,12 @@ from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
 
 from plugin import plugins as inventree_plugins
 from plugin.integration import IntegrationPluginBase
-from plugin.helpers import get_plugin_error
+from plugin.helpers import get_plugin_error, IntegrationPluginError
 
 
 logger = logging.getLogger('inventree')
 
 
-class PluginLoadingError(Exception):
-    def __init__(self, path, message):
-        self.path = path
-        self.message = message
-    
-    def __str__(self):
-        return self.message
-
-
 class PluginAppConfig(AppConfig):
     name = 'plugin'
 
@@ -69,7 +60,7 @@ class PluginAppConfig(AppConfig):
             except (OperationalError, ProgrammingError):
                 # Exception if the database has not been migrated yet
                 logger.info('Database not accessible while loading plugins')
-            except PluginLoadingError as error:
+            except IntegrationPluginError as error:
                 logger.error(f'Encountered an error with {error.path}:\n{error.message}')
                 log_plugin_error({error.path: error.message}, 'load')
                 blocked_plugin = error.path  # we will not try to load this app again
@@ -140,7 +131,7 @@ class PluginAppConfig(AppConfig):
 
         :param disabled: loading path of disabled app, defaults to None
         :type disabled: str, optional
-        :raises error: PluginLoadingError
+        :raises error: IntegrationPluginError
         """
         from plugin.helpers import log_plugin_error
         from plugin.models import PluginConfig
@@ -415,6 +406,6 @@ class PluginAppConfig(AppConfig):
             cmd(*args, **kwargs)
             return True, []
         except Exception as error:
-            raise PluginLoadingError(get_plugin_error(error))
+            get_plugin_error(error, do_raise=True)
     # endregion
     # endregion
diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 46c92bb039..3c1edd82fd 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -14,8 +14,20 @@ def log_plugin_error(error, reference: str = 'general'):
     # add error to stack
     settings.INTEGRATION_ERRORS[reference].append(error)
 
-def get_plugin_error(error):
+
+class IntegrationPluginError(Exception):
+    def __init__(self, path, message):
+        self.path = path
+        self.message = message
+    
+    def __str__(self):
+        return self.message
+
+
+def get_plugin_error(error, do_raise: bool = False):
     package_path = traceback.extract_tb(error.__traceback__)[-1].filename
     install_path = sysconfig.get_paths()["purelib"]
     package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
+    if do_raise:
+        raise IntegrationPluginError(package_name, str(error))
     return package_name, str(error)

From e925095503de4074a2ea883d864100f92f7a8401 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 13:26:37 +0100
Subject: [PATCH 400/493] pack logging into custom error processing

---
 InvenTree/plugin/helpers.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 3c1edd82fd..a832f5d92f 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -24,10 +24,18 @@ class IntegrationPluginError(Exception):
         return self.message
 
 
-def get_plugin_error(error, do_raise: bool = False):
+def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_name: str = ''):
     package_path = traceback.extract_tb(error.__traceback__)[-1].filename
     install_path = sysconfig.get_paths()["purelib"]
     package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
+
+    if do_log:
+        log_kwargs = {}
+        if log_name:
+            log_kwargs['reference'] = log_name
+        log_plugin_error({package_name: str(error)}, **log_kwargs)
+
     if do_raise:
         raise IntegrationPluginError(package_name, str(error))
+
     return package_name, str(error)

From 57aefc81002aef9adc119498c0e1165edf25398f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 13:37:16 +0100
Subject: [PATCH 401/493] wrapper to log failing urls

---
 InvenTree/plugin/urls.py | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 29c6ecb32e..8daa5041e2 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -4,6 +4,8 @@ URL lookup for plugin app
 from django.conf import settings
 from django.conf.urls import url, include
 
+from plugin.helpers import get_plugin_error
+
 
 PLUGIN_BASE = 'plugin'  # Constant for links
 
@@ -14,4 +16,16 @@ def get_plugin_urls():
     for plugin in settings.INTEGRATION_PLUGINS.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
+    # TODO wrap everything in plugin_url_wrapper
     return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
+
+
+def plugin_url_wrapper(view):
+    """wrapper to catch errors and log them to plugin error stack"""
+    def f(request, *args, **kwargs):
+        try:
+            return view(request, *args, **kwargs)
+        except Exception as error:
+            get_plugin_error(error, do_log=True, log_name='view')
+            # TODO disable if in production
+    return f

From 67fa4cc1197d2984497a3bbbc1193ff632ec0fb3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 13:44:31 +0100
Subject: [PATCH 402/493] PEP fix

---
 InvenTree/InvenTree/helpers.py | 2 --
 InvenTree/plugin/apps.py       | 2 +-
 2 files changed, 1 insertion(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 2b511bc09f..9dd32c694f 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -14,8 +14,6 @@ from wsgiref.util import FileWrapper
 from django.http import StreamingHttpResponse
 from django.core.exceptions import ValidationError, FieldError
 from django.utils.translation import ugettext_lazy as _
-from django.conf import settings
-
 from django.contrib.auth.models import Permission
 
 import InvenTree.version
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 2d3d1d6181..9d08de75c9 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -9,7 +9,7 @@ from importlib import reload
 from django.apps import AppConfig, apps
 from django.conf import settings
 from django.db.utils import OperationalError, ProgrammingError
-from django.conf.urls import url, include
+from django.conf.urls import url
 from django.urls import clear_url_caches
 from django.contrib import admin
 from django.utils.text import slugify

From 8a2a06955fa247e18a3b01e29ee8fc0d27716203 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 15:49:02 +0100
Subject: [PATCH 403/493] small fixes

---
 InvenTree/InvenTree/helpers.py                 | 1 +
 InvenTree/plugin/builtin/integration/mixins.py | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 9dd32c694f..fd86306627 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -14,6 +14,7 @@ from wsgiref.util import FileWrapper
 from django.http import StreamingHttpResponse
 from django.core.exceptions import ValidationError, FieldError
 from django.utils.translation import ugettext_lazy as _
+
 from django.contrib.auth.models import Permission
 
 import InvenTree.version
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index edf9a2c474..3a6b558db7 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -1,4 +1,4 @@
-"""default shpping mixins for IntegrationMixins"""
+"""default mixins for IntegrationMixins"""
 from django.conf.urls import url, include
 
 from plugin.urls import PLUGIN_BASE

From ebe5993a45fe77858e20dccb62650714b68db4df Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 16:31:02 +0100
Subject: [PATCH 404/493] refactor registry into own class and file

---
 InvenTree/plugin/apps.py     | 403 +---------------------------------
 InvenTree/plugin/registry.py | 409 +++++++++++++++++++++++++++++++++++
 2 files changed, 413 insertions(+), 399 deletions(-)
 create mode 100644 InvenTree/plugin/registry.py

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 9d08de75c9..c184803506 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -1,33 +1,10 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
-import importlib
-import pathlib
-import logging
-from typing import OrderedDict
-from importlib import reload
 
-from django.apps import AppConfig, apps
+from django.apps import AppConfig
 from django.conf import settings
-from django.db.utils import OperationalError, ProgrammingError
-from django.conf.urls import url
-from django.urls import clear_url_caches
-from django.contrib import admin
-from django.utils.text import slugify
 
-try:
-    from importlib import metadata
-except:
-    import importlib_metadata as metadata
-
-from maintenance_mode.core import maintenance_mode_on
-from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
-
-from plugin import plugins as inventree_plugins
-from plugin.integration import IntegrationPluginBase
-from plugin.helpers import get_plugin_error, IntegrationPluginError
-
-
-logger = logging.getLogger('inventree')
+from plugin.registry import plugins
 
 
 class PluginAppConfig(AppConfig):
@@ -35,377 +12,5 @@ class PluginAppConfig(AppConfig):
 
     def ready(self):
         if not settings.INTEGRATION_PLUGINS_RELOADING:
-            self._collect_plugins()
-            self.load_plugins()
-
-    # region public plugin functions
-    def load_plugins(self):
-        """load and activate all IntegrationPlugins"""
-        from plugin.helpers import log_plugin_error
-
-        logger.info('Start loading plugins')
-        # set maintanace mode
-        _maintenance = bool(get_maintenance_mode())
-        if not _maintenance:
-            set_maintenance_mode(True)
-
-        registered_sucessfull = False
-        blocked_plugin = None
-        while not registered_sucessfull:
-            try:
-                # 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
-            except (OperationalError, ProgrammingError):
-                # Exception if the database has not been migrated yet
-                logger.info('Database not accessible while loading plugins')
-            except IntegrationPluginError as error:
-                logger.error(f'Encountered an error with {error.path}:\n{error.message}')
-                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
-                self._clean_registry()
-                self._clean_installed_apps()
-                self._activate_plugins(force_reload=True)
-
-                # now the loading will re-start up with init
-
-        # remove maintenance
-        if not _maintenance:
-            set_maintenance_mode(False)
-        logger.info('Finished loading plugins')
-
-    def unload_plugins(self):
-        """unload and deactivate all IntegrationPlugins"""
-        logger.info('Start unloading plugins')
-        # set maintanace mode
-        _maintenance = bool(get_maintenance_mode())
-        if not _maintenance:
-            set_maintenance_mode(True)
-
-        # remove all plugins from registry
-        self._clean_registry()
-
-        # deactivate all integrations
-        self._deactivate_plugins()
-
-        # remove maintenance
-        if not _maintenance:
-            set_maintenance_mode(False)
-        logger.info('Finished unloading plugins')
-
-    def reload_plugins(self):
-        """safely reload IntegrationPlugins"""
-        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
-    def _collect_plugins(self):
-        """collect integration plugins from all possible ways of loading"""
-        # Collect plugins from paths
-        for plugin in settings.PLUGIN_DIRS:
-            modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
-            if modules:
-                [settings.PLUGINS.append(item) for item in modules]
-
-        # check if 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', []):
-                plugin = entry.load()
-                plugin.is_package = True
-                settings.PLUGINS.append(plugin)
-
-        # Log found plugins
-        logger.info(f'Found {len(settings.PLUGINS)} plugins!')
-        logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
-
-    def _init_plugins(self, disabled=None):
-        """initialise all found plugins
-
-        :param disabled: loading path of disabled app, defaults to None
-        :type disabled: str, optional
-        :raises error: IntegrationPluginError
-        """
-        from plugin.helpers import log_plugin_error
-        from plugin.models import PluginConfig
-
-        logger.info('Starting plugin initialisation')
-        # Initialize integration plugins
-        for plugin in inventree_plugins.load_integration_plugins():
-            # 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!!
-            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!
-            try:
-                plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
-            except (OperationalError, ProgrammingError) as error:
-                # Exception if the database has not been migrated yet - check if test are running - raise if not
-                if not settings.PLUGIN_TESTING:
-                    raise error
-                plugin_db_setting = None
-
-            # 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
-                if disabled:
-                    if plugin.__name__ == disabled:
-                        # errors are bad so disable the plugin in the database
-                        # but only if not in testing mode as that breaks in the GH pipeline
-                        if not settings.PLUGIN_TESTING:
-                            plugin_db_setting.active = False
-                            # TODO save the error to the plugin
-
-                            log_plugin_error({plug_key: 'Disabled'}, 'init')
-                            plugin_db_setting.save()
-
-                        # add to inactive plugins so it shows up in the ui
-                        settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
-                        continue  # continue -> the plugin is not loaded
-
-                # init package
-                # now we can be sure that an admin has activated the plugin -> 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.
-                logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
-                plugin = plugin()
-                logger.info(f'Loaded integration plugin {plugin.slug}')
-                plugin.is_package = was_packaged
-                if plugin_db_setting:
-                    plugin.pk = plugin_db_setting.pk
-
-                # safe reference
-                settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
-            else:
-                # save for later reference
-                settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
-
-    def _activate_plugins(self, force_reload=False):
-        """run integration functions for all plugins
-
-        :param force_reload: force reload base apps, defaults to False
-        :type force_reload: bool, optional
-        """
-        # activate integrations
-        plugins = settings.INTEGRATION_PLUGINS.items()
-        logger.info(f'Found {len(plugins)} active plugins')
-
-        self.activate_integration_globalsettings(plugins)
-        self.activate_integration_app(plugins, force_reload=force_reload)
-
-    def _deactivate_plugins(self):
-        """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
-
-        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
-            logger.info('Registering IntegrationPlugin global settings')
-            for slug, plugin in plugins:
-                if plugin.mixin_enabled('globalsettings'):
-                    plugin_setting = plugin.globalsettingspatterns
-                    settings.INTEGRATION_PLUGIN_GLOBALSETTING[slug] = plugin_setting
-
-                    # Add to settings dir
-                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
-
-    def deactivate_integration_globalsettings(self):
-        from common.models import InvenTreeSetting
-
-        # collect all settings
-        plugin_settings = {}
-        for _, plugin_setting in settings.INTEGRATION_PLUGIN_GLOBALSETTING.items():
-            plugin_settings.update(plugin_setting)
-
-        # remove settings
-        for setting in plugin_settings:
-            InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
-
-        # clear cache
-        settings.INTEGRATION_PLUGIN_GLOBALSETTING = {}
-    # endregion
-
-    # region integration_app
-    def activate_integration_app(self, plugins, force_reload=False):
-        """activate AppMixin plugins - add custom apps and reload
-
-        :param plugins: list of IntegrationPlugins that should be installed
-        :type plugins: dict
-        :param force_reload: only reload base apps, defaults to False
-        :type force_reload: bool, optional
-        """
-        from common.models import InvenTreeSetting
-
-        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
-            logger.info('Registering IntegrationPlugin apps')
-            apps_changed = False
-
-            # add them to the INSTALLED_APPS
-            for slug, plugin in plugins:
-                if plugin.mixin_enabled('app'):
-                    plugin_path = self._get_plugin_path(plugin)
-                    if plugin_path not in settings.INSTALLED_APPS:
-                        settings.INSTALLED_APPS += [plugin_path]
-                        settings.INTEGRATION_APPS_PATHS += [plugin_path]
-                        apps_changed = True
-
-            if apps_changed or force_reload:
-                # if apps were changed or force loading base apps -> reload
-                if settings.INTEGRATION_APPS_LOADING or force_reload:
-                    # first startup or force loading of base apps -> registry is prob false
-                    settings.INTEGRATION_APPS_LOADING = False
-                    self._reload_apps(force_reload=True)
-                self._reload_apps()
-                # rediscover models/ admin sites
-                self._reregister_contrib_apps()
-                # update urls - must be last as models must be registered for creating admin routes
-                self._update_urls()
-
-    def _reregister_contrib_apps(self):
-        """fix reloading of contrib apps - models and admin
-        this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
-        those register models and admin in their respective objects (e.g. admin.site for admin)
-        """
-        for plugin_path in settings.INTEGRATION_APPS_PATHS:
-            try:
-                app_name = plugin_path.split('.')[-1]
-                app_config = apps.get_app_config(app_name)
-            except LookupError:
-                # the plugin was never loaded correctly
-                logger.debug(f'{app_name} App was not found during deregistering')
-                break
-
-            # reload models if they were set
-            # models_module gets set if models were defined - even after multiple loads
-            # on a reload the models registery is empty but models_module is not
-            if app_config.models_module and len(app_config.models) == 0:
-                reload(app_config.models_module)
-
-            # check for all models if they are registered with the site admin
-            model_not_reg = False
-            for model in app_config.get_models():
-                if not admin.site.is_registered(model):
-                    model_not_reg = True
-
-            # reload admin if at least one model is not registered
-            # models are registered with admin in the 'admin.py' file - so we check
-            # if the app_config has an admin module before trying to laod it
-            if model_not_reg and hasattr(app_config.module, 'admin'):
-                reload(app_config.module.admin)
-
-    def _get_plugin_path(self, plugin):
-        """parse plugin path
-        the input can be eiter:
-        - a local file / dir
-        - a package
-        """
-        try:
-            # for local path plugins
-            plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
-        except ValueError:
-            # plugin is shipped as package
-            plugin_path = plugin.PLUGIN_NAME
-        return plugin_path
-
-    def deactivate_integration_app(self):
-        """deactivate integration app - some magic required"""
-        # unregister models from admin
-        for plugin_path in settings.INTEGRATION_APPS_PATHS:
-            models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed
-            app_name = plugin_path.split('.')[-1]
-            try:
-                app_config = apps.get_app_config(app_name)
-
-                # check all models
-                for model in app_config.get_models():
-                    # remove model from admin site
-                    admin.site.unregister(model)
-                    models += [model._meta.model_name]
-            except LookupError:
-                # if an error occurs the app was never loaded right -> so nothing to do anymore
-                logger.debug(f'{app_name} App was not found during deregistering')
-                break
-
-            # unregister the models (yes, models are just kept in multilevel dicts)
-            for model in models:
-                # remove model from general registry
-                apps.all_models[plugin_path].pop(model)
-
-            # clear the registry for that app
-            # so that the import trick will work on reloading the same plugin
-            # -> the registry is kept for the whole lifecycle
-            if models and app_name in apps.all_models:
-                apps.all_models.pop(app_name)
-
-        # remove plugin from installed_apps
-        self._clean_installed_apps()
-
-        # reset load flag and reload apps
-        settings.INTEGRATION_APPS_LOADED = False
-        self._reload_apps()
-
-        # update urls to remove the apps from the site admin
-        self._update_urls()
-
-    def _clean_installed_apps(self):
-        for plugin in settings.INTEGRATION_APPS_PATHS:
-            if plugin in settings.INSTALLED_APPS:
-                settings.INSTALLED_APPS.remove(plugin)
-
-        settings.INTEGRATION_APPS_PATHS = []
-
-    def _clean_registry(self):
-        # remove all plugins from registry
-        settings.INTEGRATION_PLUGINS = {}
-        settings.INTEGRATION_PLUGINS_INACTIVE = {}
-
-    def _update_urls(self):
-        from InvenTree.urls import urlpatterns
-        from plugin.urls import get_plugin_urls
-
-        for index, a in enumerate(urlpatterns):
-            if hasattr(a, 'app_name'):
-                if a.app_name == 'admin':
-                    urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
-                elif a.app_name == 'plugin':
-                    urlpatterns[index] = get_plugin_urls()
-        clear_url_caches()
-
-    def _reload_apps(self, force_reload: bool = False):
-        if force_reload:
-            # we can not use the built in functions as we need to brute force the registry
-            apps.app_configs = OrderedDict()
-            apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
-            apps.clear_cache()
-            self._try_reload(apps.populate, settings.INSTALLED_APPS)
-
-        settings.INTEGRATION_PLUGINS_RELOADING = True
-        self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
-        settings.INTEGRATION_PLUGINS_RELOADING = False
-
-    def _try_reload(self, cmd, *args, **kwargs):
-        """
-        wrapper to try reloading the apps
-        throws an custom error that gets handled by the loading function
-        """
-        try:
-            cmd(*args, **kwargs)
-            return True, []
-        except Exception as error:
-            get_plugin_error(error, do_raise=True)
-    # endregion
-    # endregion
+            plugins.collect_plugins()
+            plugins.load_plugins()
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
new file mode 100644
index 0000000000..ad4098b95b
--- /dev/null
+++ b/InvenTree/plugin/registry.py
@@ -0,0 +1,409 @@
+"""
+registry for plugins
+holds the class and the object that contains all code to maintain plugin states
+"""
+import importlib
+import pathlib
+import logging
+from typing import OrderedDict
+from importlib import reload
+
+from django.apps import apps
+from django.conf import settings
+from django.db.utils import OperationalError, ProgrammingError
+from django.conf.urls import url
+from django.urls import clear_url_caches
+from django.contrib import admin
+from django.utils.text import slugify
+
+try:
+    from importlib import metadata
+except:
+    import importlib_metadata as metadata
+
+from maintenance_mode.core import maintenance_mode_on
+from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
+
+from plugin import plugins as inventree_plugins
+from plugin.integration import IntegrationPluginBase
+from plugin.helpers import get_plugin_error, IntegrationPluginError
+
+
+logger = logging.getLogger('inventree')
+
+
+class Plugins:
+    # region public plugin functions
+    def load_plugins(self):
+        """load and activate all IntegrationPlugins"""
+        from plugin.helpers import log_plugin_error
+
+        logger.info('Start loading plugins')
+        # set maintanace mode
+        _maintenance = bool(get_maintenance_mode())
+        if not _maintenance:
+            set_maintenance_mode(True)
+
+        registered_sucessfull = False
+        blocked_plugin = None
+        while not registered_sucessfull:
+            try:
+                # 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
+            except (OperationalError, ProgrammingError):
+                # Exception if the database has not been migrated yet
+                logger.info('Database not accessible while loading plugins')
+            except IntegrationPluginError as error:
+                logger.error(f'Encountered an error with {error.path}:\n{error.message}')
+                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
+                self._clean_registry()
+                self._clean_installed_apps()
+                self._activate_plugins(force_reload=True)
+
+                # now the loading will re-start up with init
+
+        # remove maintenance
+        if not _maintenance:
+            set_maintenance_mode(False)
+        logger.info('Finished loading plugins')
+
+    def unload_plugins(self):
+        """unload and deactivate all IntegrationPlugins"""
+        logger.info('Start unloading plugins')
+        # set maintanace mode
+        _maintenance = bool(get_maintenance_mode())
+        if not _maintenance:
+            set_maintenance_mode(True)
+
+        # remove all plugins from registry
+        self._clean_registry()
+
+        # deactivate all integrations
+        self._deactivate_plugins()
+
+        # remove maintenance
+        if not _maintenance:
+            set_maintenance_mode(False)
+        logger.info('Finished unloading plugins')
+
+    def reload_plugins(self):
+        """safely reload IntegrationPlugins"""
+        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
+    def collect_plugins(self):
+        """collect integration plugins from all possible ways of loading"""
+        # Collect plugins from paths
+        for plugin in settings.PLUGIN_DIRS:
+            modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
+            if modules:
+                [settings.PLUGINS.append(item) for item in modules]
+
+        # check if 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', []):
+                plugin = entry.load()
+                plugin.is_package = True
+                settings.PLUGINS.append(plugin)
+
+        # Log found plugins
+        logger.info(f'Found {len(settings.PLUGINS)} plugins!')
+        logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
+
+    def _init_plugins(self, disabled=None):
+        """initialise all found plugins
+
+        :param disabled: loading path of disabled app, defaults to None
+        :type disabled: str, optional
+        :raises error: IntegrationPluginError
+        """
+        from plugin.helpers import log_plugin_error
+        from plugin.models import PluginConfig
+
+        logger.info('Starting plugin initialisation')
+        # Initialize integration plugins
+        for plugin in inventree_plugins.load_integration_plugins():
+            # 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!!
+            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!
+            try:
+                plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name)
+            except (OperationalError, ProgrammingError) as error:
+                # Exception if the database has not been migrated yet - check if test are running - raise if not
+                if not settings.PLUGIN_TESTING:
+                    raise error
+                plugin_db_setting = None
+
+            # 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
+                if disabled:
+                    if plugin.__name__ == disabled:
+                        # errors are bad so disable the plugin in the database
+                        # but only if not in testing mode as that breaks in the GH pipeline
+                        if not settings.PLUGIN_TESTING:
+                            plugin_db_setting.active = False
+                            # TODO save the error to the plugin
+
+                            log_plugin_error({plug_key: 'Disabled'}, 'init')
+                            plugin_db_setting.save()
+
+                        # add to inactive plugins so it shows up in the ui
+                        settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+                        continue  # continue -> the plugin is not loaded
+
+                # init package
+                # now we can be sure that an admin has activated the plugin -> 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.
+                logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
+                plugin = plugin()
+                logger.info(f'Loaded integration plugin {plugin.slug}')
+                plugin.is_package = was_packaged
+                if plugin_db_setting:
+                    plugin.pk = plugin_db_setting.pk
+
+                # safe reference
+                settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+            else:
+                # save for later reference
+                settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+
+    def _activate_plugins(self, force_reload=False):
+        """run integration functions for all plugins
+
+        :param force_reload: force reload base apps, defaults to False
+        :type force_reload: bool, optional
+        """
+        # activate integrations
+        plugins = settings.INTEGRATION_PLUGINS.items()
+        logger.info(f'Found {len(plugins)} active plugins')
+
+        self.activate_integration_globalsettings(plugins)
+        self.activate_integration_app(plugins, force_reload=force_reload)
+
+    def _deactivate_plugins(self):
+        """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
+
+        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
+            logger.info('Registering IntegrationPlugin global settings')
+            for slug, plugin in plugins:
+                if plugin.mixin_enabled('globalsettings'):
+                    plugin_setting = plugin.globalsettingspatterns
+                    settings.INTEGRATION_PLUGIN_GLOBALSETTING[slug] = plugin_setting
+
+                    # Add to settings dir
+                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
+
+    def deactivate_integration_globalsettings(self):
+        from common.models import InvenTreeSetting
+
+        # collect all settings
+        plugin_settings = {}
+        for _, plugin_setting in settings.INTEGRATION_PLUGIN_GLOBALSETTING.items():
+            plugin_settings.update(plugin_setting)
+
+        # remove settings
+        for setting in plugin_settings:
+            InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
+
+        # clear cache
+        settings.INTEGRATION_PLUGIN_GLOBALSETTING = {}
+    # endregion
+
+    # region integration_app
+    def activate_integration_app(self, plugins, force_reload=False):
+        """activate AppMixin plugins - add custom apps and reload
+
+        :param plugins: list of IntegrationPlugins that should be installed
+        :type plugins: dict
+        :param force_reload: only reload base apps, defaults to False
+        :type force_reload: bool, optional
+        """
+        from common.models import InvenTreeSetting
+
+        if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_APP'):
+            logger.info('Registering IntegrationPlugin apps')
+            apps_changed = False
+
+            # add them to the INSTALLED_APPS
+            for slug, plugin in plugins:
+                if plugin.mixin_enabled('app'):
+                    plugin_path = self._get_plugin_path(plugin)
+                    if plugin_path not in settings.INSTALLED_APPS:
+                        settings.INSTALLED_APPS += [plugin_path]
+                        settings.INTEGRATION_APPS_PATHS += [plugin_path]
+                        apps_changed = True
+
+            if apps_changed or force_reload:
+                # if apps were changed or force loading base apps -> reload
+                if settings.INTEGRATION_APPS_LOADING or force_reload:
+                    # first startup or force loading of base apps -> registry is prob false
+                    settings.INTEGRATION_APPS_LOADING = False
+                    self._reload_apps(force_reload=True)
+                self._reload_apps()
+                # rediscover models/ admin sites
+                self._reregister_contrib_apps()
+                # update urls - must be last as models must be registered for creating admin routes
+                self._update_urls()
+
+    def _reregister_contrib_apps(self):
+        """fix reloading of contrib apps - models and admin
+        this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
+        those register models and admin in their respective objects (e.g. admin.site for admin)
+        """
+        for plugin_path in settings.INTEGRATION_APPS_PATHS:
+            try:
+                app_name = plugin_path.split('.')[-1]
+                app_config = apps.get_app_config(app_name)
+            except LookupError:
+                # the plugin was never loaded correctly
+                logger.debug(f'{app_name} App was not found during deregistering')
+                break
+
+            # reload models if they were set
+            # models_module gets set if models were defined - even after multiple loads
+            # on a reload the models registery is empty but models_module is not
+            if app_config.models_module and len(app_config.models) == 0:
+                reload(app_config.models_module)
+
+            # check for all models if they are registered with the site admin
+            model_not_reg = False
+            for model in app_config.get_models():
+                if not admin.site.is_registered(model):
+                    model_not_reg = True
+
+            # reload admin if at least one model is not registered
+            # models are registered with admin in the 'admin.py' file - so we check
+            # if the app_config has an admin module before trying to laod it
+            if model_not_reg and hasattr(app_config.module, 'admin'):
+                reload(app_config.module.admin)
+
+    def _get_plugin_path(self, plugin):
+        """parse plugin path
+        the input can be eiter:
+        - a local file / dir
+        - a package
+        """
+        try:
+            # for local path plugins
+            plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts)
+        except ValueError:
+            # plugin is shipped as package
+            plugin_path = plugin.PLUGIN_NAME
+        return plugin_path
+
+    def deactivate_integration_app(self):
+        """deactivate integration app - some magic required"""
+        # unregister models from admin
+        for plugin_path in settings.INTEGRATION_APPS_PATHS:
+            models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed
+            app_name = plugin_path.split('.')[-1]
+            try:
+                app_config = apps.get_app_config(app_name)
+
+                # check all models
+                for model in app_config.get_models():
+                    # remove model from admin site
+                    admin.site.unregister(model)
+                    models += [model._meta.model_name]
+            except LookupError:
+                # if an error occurs the app was never loaded right -> so nothing to do anymore
+                logger.debug(f'{app_name} App was not found during deregistering')
+                break
+
+            # unregister the models (yes, models are just kept in multilevel dicts)
+            for model in models:
+                # remove model from general registry
+                apps.all_models[plugin_path].pop(model)
+
+            # clear the registry for that app
+            # so that the import trick will work on reloading the same plugin
+            # -> the registry is kept for the whole lifecycle
+            if models and app_name in apps.all_models:
+                apps.all_models.pop(app_name)
+
+        # remove plugin from installed_apps
+        self._clean_installed_apps()
+
+        # reset load flag and reload apps
+        settings.INTEGRATION_APPS_LOADED = False
+        self._reload_apps()
+
+        # update urls to remove the apps from the site admin
+        self._update_urls()
+
+    def _clean_installed_apps(self):
+        for plugin in settings.INTEGRATION_APPS_PATHS:
+            if plugin in settings.INSTALLED_APPS:
+                settings.INSTALLED_APPS.remove(plugin)
+
+        settings.INTEGRATION_APPS_PATHS = []
+
+    def _clean_registry(self):
+        # remove all plugins from registry
+        settings.INTEGRATION_PLUGINS = {}
+        settings.INTEGRATION_PLUGINS_INACTIVE = {}
+
+    def _update_urls(self):
+        from InvenTree.urls import urlpatterns
+        from plugin.urls import get_plugin_urls
+
+        for index, a in enumerate(urlpatterns):
+            if hasattr(a, 'app_name'):
+                if a.app_name == 'admin':
+                    urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
+                elif a.app_name == 'plugin':
+                    urlpatterns[index] = get_plugin_urls()
+        clear_url_caches()
+
+    def _reload_apps(self, force_reload: bool = False):
+        if force_reload:
+            # we can not use the built in functions as we need to brute force the registry
+            apps.app_configs = OrderedDict()
+            apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
+            apps.clear_cache()
+            self._try_reload(apps.populate, settings.INSTALLED_APPS)
+
+        settings.INTEGRATION_PLUGINS_RELOADING = True
+        self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
+        settings.INTEGRATION_PLUGINS_RELOADING = False
+
+    def _try_reload(self, cmd, *args, **kwargs):
+        """
+        wrapper to try reloading the apps
+        throws an custom error that gets handled by the loading function
+        """
+        try:
+            cmd(*args, **kwargs)
+            return True, []
+        except Exception as error:
+            get_plugin_error(error, do_raise=True)
+    # endregion
+    # endregion
+
+
+plugins = Plugins()

From b596e4f164e00808d1dc7da1907ed366cefd1d90 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 16:37:54 +0100
Subject: [PATCH 405/493] remove unneeded stuff from broken sample and optimize
 for coverage

---
 InvenTree/plugin/samples/integration/broken_sample.py | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py
index 191e673176..fe145b44e5 100644
--- a/InvenTree/plugin/samples/integration/broken_sample.py
+++ b/InvenTree/plugin/samples/integration/broken_sample.py
@@ -1,12 +1,11 @@
-"""sample implementation for IntegrationPlugin"""
+"""sample of a broken python file that will be ignroed on import"""
 from plugin.integration import IntegrationPluginBase
 
-aaa = bb  # noqa: F821
-
 
 class BrokenIntegrationPlugin(IntegrationPluginBase):
     """
     An very broken integration plugin
     """
 
-    PLUGIN_NAME = "BrokenIntegrationPlugin"
+
+aaa = bb  # noqa: F821

From 076cca5e622fdd0efcafc0c85b7fa2869704ce94 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 16:43:39 +0100
Subject: [PATCH 406/493] add TODO for dependency

---
 InvenTree/plugin/registry.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index ad4098b95b..85f58e8ec6 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -20,6 +20,7 @@ try:
     from importlib import metadata
 except:
     import importlib_metadata as metadata
+    # TODO remove when python minimum is 3.8
 
 from maintenance_mode.core import maintenance_mode_on
 from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode

From 5f83fd007f2f68906cca86531fb91ea74b5b2326 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 17:02:27 +0100
Subject: [PATCH 407/493] more structure

---
 InvenTree/plugin/helpers.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index a832f5d92f..3f7fe39ef5 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -6,6 +6,7 @@ import traceback
 from django.conf import settings
 
 
+# region logging / errors
 def log_plugin_error(error, reference: str = 'general'):
     # make sure the registry is set up
     if reference not in settings.INTEGRATION_ERRORS:
@@ -39,3 +40,4 @@ def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_na
         raise IntegrationPluginError(package_name, str(error))
 
     return package_name, str(error)
+# endregion

From 098116675a3401ec093b3203034f93f72c498bcd Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 17:03:10 +0100
Subject: [PATCH 408/493] move git stuff to the helpers

---
 InvenTree/plugin/helpers.py     | 42 +++++++++++++++++++++++++++++++++
 InvenTree/plugin/integration.py | 42 +--------------------------------
 2 files changed, 43 insertions(+), 41 deletions(-)

diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 3f7fe39ef5..55d3305da8 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -1,4 +1,6 @@
 """Helpers for plugin app"""
+import os
+import subprocess
 import pathlib
 import sysconfig
 import traceback
@@ -41,3 +43,43 @@ def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_na
 
     return package_name, str(error)
 # endregion
+
+
+# region git-helpers
+def get_git_log(path):
+    """get dict with info of the last commit to file named in path"""
+    path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
+    command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
+    try:
+        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
+        if output:
+            output = output.split('\n')
+        else:
+            output = 7 * ['']
+    except subprocess.CalledProcessError:
+        output = 7 * ['']
+    return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
+
+
+class GitStatus:
+    """class for resolving git gpg singing state"""
+    class Definition:
+        """definition of a git gpg sing state"""
+        key: str = 'N'
+        status: int = 2
+        msg: str = ''
+
+        def __init__(self, key: str = 'N', status: int = 2, msg: str = '') -> None:
+            self.key = key
+            self.status = status
+            self.msg = msg
+
+    N = Definition(key='N', status=2, msg='no signature',)
+    G = Definition(key='G', status=0, msg='valid signature',)
+    B = Definition(key='B', status=2, msg='bad signature',)
+    U = Definition(key='U', status=1, msg='good signature, unknown validity',)
+    X = Definition(key='X', status=1, msg='good signature, expired',)
+    Y = Definition(key='Y', status=1, msg='good signature, expired key',)
+    R = Definition(key='R', status=2, msg='good signature, revoked key',)
+    E = Definition(key='E', status=1, msg='cannot be checked',)
+# endregion
diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index a06e4658ed..3cd8ae86d2 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -3,7 +3,6 @@
 
 import logging
 import os
-import subprocess
 import inspect
 from datetime import datetime
 import pathlib
@@ -14,6 +13,7 @@ from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 
 import plugin.plugin as plugin
+from plugin.helpers import get_git_log, GitStatus
 
 
 logger = logging.getLogger("inventree")
@@ -55,46 +55,6 @@ class MixinBase:
         return mixins
 
 
-# region git-helpers
-def get_git_log(path):
-    """get dict with info of the last commit to file named in path"""
-    path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
-    command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
-    try:
-        output = str(subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8')[1:-1]
-        if output:
-            output = output.split('\n')
-        else:
-            output = 7 * ['']
-    except subprocess.CalledProcessError:
-        output = 7 * ['']
-    return {'hash': output[0], 'author': output[1], 'mail': output[2], 'date': output[3], 'message': output[4], 'verified': output[5], 'key': output[6]}
-
-
-class GitStatus:
-    """class for resolving git gpg singing state"""
-    class Definition:
-        """definition of a git gpg sing state"""
-        key: str = 'N'
-        status: int = 2
-        msg: str = ''
-
-        def __init__(self, key: str = 'N', status: int = 2, msg: str = '') -> None:
-            self.key = key
-            self.status = status
-            self.msg = msg
-
-    N = Definition(key='N', status=2, msg='no signature',)
-    G = Definition(key='G', status=0, msg='valid signature',)
-    B = Definition(key='B', status=2, msg='bad signature',)
-    U = Definition(key='U', status=1, msg='good signature, unknown validity',)
-    X = Definition(key='X', status=1, msg='good signature, expired',)
-    Y = Definition(key='Y', status=1, msg='good signature, expired key',)
-    R = Definition(key='R', status=2, msg='good signature, revoked key',)
-    E = Definition(key='E', status=1, msg='cannot be checked',)
-# endregion
-
-
 class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     """
     The IntegrationPluginBase class is used to integrate with 3rd party software

From e90b69262a3253c4226cf4e8fa09afb9969ca9f0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 17:12:12 +0100
Subject: [PATCH 409/493] fix import

---
 InvenTree/InvenTree/api.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py
index e53a8b96cb..8249a093aa 100644
--- a/InvenTree/InvenTree/api.py
+++ b/InvenTree/InvenTree/api.py
@@ -21,14 +21,14 @@ from .views import AjaxView
 from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
 from .status import is_worker_running
 
-from plugin import plugins as inventree_plugins
+from plugin.plugins import load_action_plugins
 
 
 logger = logging.getLogger("inventree")
 
 
 logger.info("Loading action plugins...")
-action_plugins = inventree_plugins.load_action_plugins()
+action_plugins = load_action_plugins()
 
 
 class InfoView(AjaxView):

From 8d2ad4da2eaf500cfb0f6a684b6c6f6d0ebc6b01 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 17:17:36 +0100
Subject: [PATCH 410/493] set up cleaner import paths

---
 InvenTree/plugin/__init__.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py
index e69de29bb2..97b4b9d47f 100644
--- a/InvenTree/plugin/__init__.py
+++ b/InvenTree/plugin/__init__.py
@@ -0,0 +1,7 @@
+from .registry import plugins
+from .integration import IntegrationPluginBase
+from .action import ActionPlugin
+
+__all__ = [
+    'plugins', 'IntegrationPluginBase', 'ActionPlugin',
+]

From e762ec676d2333bef1b7694bdae259977f75901a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 17:29:06 +0100
Subject: [PATCH 411/493] simplify imports

---
 InvenTree/plugin/plugins.py  | 2 +-
 InvenTree/plugin/registry.py | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 66f841d95b..2827ec590c 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -14,7 +14,7 @@ import plugin.builtin.action as action
 from plugin.action import ActionPlugin
 
 # Integration
-from plugin.integration import IntegrationPluginBase
+from .integration import IntegrationPluginBase
 
 
 logger = logging.getLogger("inventree")
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 85f58e8ec6..2e7b19baec 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -26,8 +26,8 @@ from maintenance_mode.core import maintenance_mode_on
 from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
 
 from plugin import plugins as inventree_plugins
-from plugin.integration import IntegrationPluginBase
-from plugin.helpers import get_plugin_error, IntegrationPluginError
+from .integration import IntegrationPluginBase
+from .helpers import get_plugin_error, IntegrationPluginError
 
 
 logger = logging.getLogger('inventree')

From 8ac41970ad239460ea85da1ba30d4d2ed9646d8a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 17:46:50 +0100
Subject: [PATCH 412/493] simpler imports

---
 InvenTree/plugin/mixins/__init__.py                    | 6 ++++++
 InvenTree/plugin/samples/integration/another_sample.py | 4 ++--
 InvenTree/plugin/samples/integration/broken_sample.py  | 2 +-
 InvenTree/plugin/samples/integration/sample.py         | 4 ++--
 InvenTree/plugin/test_integration.py                   | 4 ++--
 5 files changed, 13 insertions(+), 7 deletions(-)
 create mode 100644 InvenTree/plugin/mixins/__init__.py

diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py
new file mode 100644
index 0000000000..feb6bc3466
--- /dev/null
+++ b/InvenTree/plugin/mixins/__init__.py
@@ -0,0 +1,6 @@
+"""utility class to enable simpler imports"""
+from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
+
+__all__ = [
+    'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin',
+]
diff --git a/InvenTree/plugin/samples/integration/another_sample.py b/InvenTree/plugin/samples/integration/another_sample.py
index a82d90d1d0..9b3a3d8ec7 100644
--- a/InvenTree/plugin/samples/integration/another_sample.py
+++ b/InvenTree/plugin/samples/integration/another_sample.py
@@ -1,6 +1,6 @@
 """sample implementation for IntegrationPlugin"""
-from plugin.integration import IntegrationPluginBase
-from plugin.builtin.integration.mixins import UrlsMixin
+from plugin import IntegrationPluginBase
+from plugin.mixins import UrlsMixin
 
 
 class NoIntegrationPlugin(IntegrationPluginBase):
diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py
index fe145b44e5..d8cd60a2bc 100644
--- a/InvenTree/plugin/samples/integration/broken_sample.py
+++ b/InvenTree/plugin/samples/integration/broken_sample.py
@@ -1,5 +1,5 @@
 """sample of a broken python file that will be ignroed on import"""
-from plugin.integration import IntegrationPluginBase
+from plugin import IntegrationPluginBase
 
 
 class BrokenIntegrationPlugin(IntegrationPluginBase):
diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py
index e6597498c1..d7321f8a88 100644
--- a/InvenTree/plugin/samples/integration/sample.py
+++ b/InvenTree/plugin/samples/integration/sample.py
@@ -1,6 +1,6 @@
 """sample implementations for IntegrationPlugin"""
-from plugin.integration import IntegrationPluginBase
-from plugin.builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
+from plugin import IntegrationPluginBase
+from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
 
 from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index b9951a6831..df80016dc8 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -7,8 +7,8 @@ from django.contrib.auth import get_user_model
 
 from datetime import datetime
 
-from plugin.integration import IntegrationPluginBase
-from plugin.builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
+from plugin import IntegrationPluginBase
+from plugin.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin
 from plugin.urls import PLUGIN_BASE
 
 

From 3aa40ce3e94d40e9ecb4110089604cc2a80bd949 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:01:19 +0100
Subject: [PATCH 413/493] move settings to registry

---
 InvenTree/InvenTree/settings.py                |  2 --
 InvenTree/plugin/__init__.py                   |  4 ++--
 InvenTree/plugin/loader.py                     |  4 +++-
 InvenTree/plugin/models.py                     |  4 +++-
 InvenTree/plugin/registry.py                   | 16 ++++++++++------
 InvenTree/plugin/templatetags/plugin_extras.py |  5 +++--
 InvenTree/plugin/test_plugin.py                |  3 ++-
 InvenTree/plugin/urls.py                       |  3 ++-
 8 files changed, 25 insertions(+), 16 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 6027e60bb2..88cd09072e 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -882,8 +882,6 @@ if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
 PLUGINS = []
-INTEGRATION_PLUGINS = {}
-INTEGRATION_PLUGINS_INACTIVE = {}
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
 INTEGRATION_APPS_LOADING = True     # Marks if apps were reloaded yet
diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py
index 97b4b9d47f..973d341171 100644
--- a/InvenTree/plugin/__init__.py
+++ b/InvenTree/plugin/__init__.py
@@ -1,7 +1,7 @@
-from .registry import plugins
+from .registry import plugins as plugin_reg
 from .integration import IntegrationPluginBase
 from .action import ActionPlugin
 
 __all__ = [
-    'plugins', 'IntegrationPluginBase', 'ActionPlugin',
+    'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
 ]
diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py
index 21938f81c6..04b6fc8b8a 100644
--- a/InvenTree/plugin/loader.py
+++ b/InvenTree/plugin/loader.py
@@ -6,13 +6,15 @@ from django.conf import settings
 from django.template.loaders.filesystem import Loader as FilesystemLoader
 from pathlib import Path
 
+from plugin import plugin_reg
+
 
 class PluginTemplateLoader(FilesystemLoader):
 
     def get_dirs(self):
         dirname = 'templates'
         template_dirs = []
-        for plugin in settings.INTEGRATION_PLUGINS.values():
+        for plugin in plugin_reg.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 ab53ed52d8..f12a69ecac 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -10,6 +10,8 @@ from django.db import models
 from django.apps import apps
 from django.conf import settings
 
+from plugin import plugin_reg
+
 
 class PluginConfig(models.Model):
     """ A PluginConfig object holds settings for plugins.
@@ -64,7 +66,7 @@ class PluginConfig(models.Model):
         self.__org_active = self.active
 
         # append settings from registry
-        self.plugin = settings.INTEGRATION_PLUGINS.get(self.key, None)
+        self.plugin = plugin_reg.plugins.get(self.key, None)
 
         def get_plugin_meta(name):
             if self.plugin:
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 2e7b19baec..8bb18acaac 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -34,6 +34,10 @@ logger = logging.getLogger('inventree')
 
 
 class Plugins:
+    def __init__(self) -> None:
+        self.plugins = {}
+        self.plugins_inactive = {}
+
     # region public plugin functions
     def load_plugins(self):
         """load and activate all IntegrationPlugins"""
@@ -166,7 +170,7 @@ class Plugins:
                             plugin_db_setting.save()
 
                         # add to inactive plugins so it shows up in the ui
-                        settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+                        self.plugins_inactive[plug_key] = plugin_db_setting
                         continue  # continue -> the plugin is not loaded
 
                 # init package
@@ -180,10 +184,10 @@ class Plugins:
                     plugin.pk = plugin_db_setting.pk
 
                 # safe reference
-                settings.INTEGRATION_PLUGINS[plugin.slug] = plugin
+                self.plugins[plugin.slug] = plugin
             else:
                 # save for later reference
-                settings.INTEGRATION_PLUGINS_INACTIVE[plug_key] = plugin_db_setting
+                self.plugins_inactive[plug_key] = plugin_db_setting
 
     def _activate_plugins(self, force_reload=False):
         """run integration functions for all plugins
@@ -192,7 +196,7 @@ class Plugins:
         :type force_reload: bool, optional
         """
         # activate integrations
-        plugins = settings.INTEGRATION_PLUGINS.items()
+        plugins = self.plugins.items()
         logger.info(f'Found {len(plugins)} active plugins')
 
         self.activate_integration_globalsettings(plugins)
@@ -366,8 +370,8 @@ class Plugins:
 
     def _clean_registry(self):
         # remove all plugins from registry
-        settings.INTEGRATION_PLUGINS = {}
-        settings.INTEGRATION_PLUGINS_INACTIVE = {}
+        self.plugins = {}
+        self.plugins_inactive = {}
 
     def _update_urls(self):
         from InvenTree.urls import urlpatterns
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 480e6cd5d3..140d2d5298 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -7,6 +7,7 @@ from django import template
 from django.urls import reverse
 
 from common.models import InvenTreeSetting
+from plugin import plugin_reg
 
 
 register = template.Library()
@@ -15,13 +16,13 @@ register = template.Library()
 @register.simple_tag()
 def plugin_list(*args, **kwargs):
     """ Return a list of all installed integration plugins """
-    return djangosettings.INTEGRATION_PLUGINS
+    return plugin_reg.plugins
 
 
 @register.simple_tag()
 def inactive_plugin_list(*args, **kwargs):
     """ Return a list of all inactive integration plugins """
-    return djangosettings.INTEGRATION_PLUGINS_INACTIVE
+    return plugin_reg.plugins_inactive
 
 
 @register.simple_tag()
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 45d91d70d2..d127bd5be8 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -9,6 +9,7 @@ from plugin.samples.integration.sample import SampleIntegrationPlugin
 from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
 from plugin.plugins import load_integration_plugins  # , load_action_plugins, load_barcode_plugins
 import plugin.templatetags.plugin_extras as plugin_tags
+from plugin import plugin_reg
 
 
 class InvenTreePluginTests(TestCase):
@@ -57,7 +58,7 @@ class PluginTagTests(TestCase):
 
     def test_tag_plugin_list(self):
         """test that all plugins are listed"""
-        self.assertEqual(plugin_tags.plugin_list(), settings.INTEGRATION_PLUGINS)
+        self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins)
 
     def test_tag_plugin_globalsettings(self):
         """check all plugins are listed"""
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 8daa5041e2..cf55e8d6e8 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -5,6 +5,7 @@ from django.conf import settings
 from django.conf.urls import url, include
 
 from plugin.helpers import get_plugin_error
+from plugin import plugin_reg
 
 
 PLUGIN_BASE = 'plugin'  # Constant for links
@@ -13,7 +14,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 settings.INTEGRATION_PLUGINS.values():
+    for plugin in plugin_reg.plugins.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
     # TODO wrap everything in plugin_url_wrapper

From 308348f051dc73ac3feb928f8308f1f332581ec3 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:07:05 +0100
Subject: [PATCH 414/493] move flags

---
 InvenTree/InvenTree/settings.py | 1 -
 InvenTree/plugin/apps.py        | 2 +-
 InvenTree/plugin/registry.py    | 8 ++++++--
 3 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 88cd09072e..b1714c320a 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -885,7 +885,6 @@ PLUGINS = []
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
 INTEGRATION_APPS_LOADING = True     # Marks if apps were reloaded yet
-INTEGRATION_PLUGINS_RELOADING = False
 INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
 INTEGRATION_ERRORS = {}              # Holds discovering errors
 
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index c184803506..2b9f3e1d8b 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -11,6 +11,6 @@ class PluginAppConfig(AppConfig):
     name = 'plugin'
 
     def ready(self):
-        if not settings.INTEGRATION_PLUGINS_RELOADING:
+        if not plugins.is_loading:
             plugins.collect_plugins()
             plugins.load_plugins()
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 8bb18acaac..7440a7e116 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -35,9 +35,13 @@ logger = logging.getLogger('inventree')
 
 class Plugins:
     def __init__(self) -> None:
+        # plugin registry
         self.plugins = {}
         self.plugins_inactive = {}
 
+        # flags
+        self.is_loading = False
+
     # region public plugin functions
     def load_plugins(self):
         """load and activate all IntegrationPlugins"""
@@ -393,9 +397,9 @@ class Plugins:
             apps.clear_cache()
             self._try_reload(apps.populate, settings.INSTALLED_APPS)
 
-        settings.INTEGRATION_PLUGINS_RELOADING = True
+        self.is_loading = True
         self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
-        settings.INTEGRATION_PLUGINS_RELOADING = False
+        self.is_loading = False
 
     def _try_reload(self, cmd, *args, **kwargs):
         """

From 5f180b61e9dacfb6e5269bed9dccb8030110f1cd Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:11:20 +0100
Subject: [PATCH 415/493] and another flag moved

---
 InvenTree/InvenTree/settings.py | 1 -
 InvenTree/plugin/registry.py    | 5 +++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index b1714c320a..9c1c14ce31 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -884,7 +884,6 @@ if DEBUG or TESTING:
 PLUGINS = []
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
-INTEGRATION_APPS_LOADING = True     # Marks if apps were reloaded yet
 INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
 INTEGRATION_ERRORS = {}              # Holds discovering errors
 
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 7440a7e116..7e4d4031e8 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -41,6 +41,7 @@ class Plugins:
 
         # flags
         self.is_loading = False
+        self.apps_loading = True     # Marks if apps were reloaded yet
 
     # region public plugin functions
     def load_plugins(self):
@@ -269,9 +270,9 @@ class Plugins:
 
             if apps_changed or force_reload:
                 # if apps were changed or force loading base apps -> reload
-                if settings.INTEGRATION_APPS_LOADING or force_reload:
+                if self.apps_loading or force_reload:
                     # first startup or force loading of base apps -> registry is prob false
-                    settings.INTEGRATION_APPS_LOADING = False
+                    self.apps_loading = False
                     self._reload_apps(force_reload=True)
                 self._reload_apps()
                 # rediscover models/ admin sites

From 8fbbcb3a8d474c93900772bd4ef05d34cf28ae7a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:13:33 +0100
Subject: [PATCH 416/493] better readability

---
 InvenTree/plugin/registry.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 7e4d4031e8..da6659fffb 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -268,15 +268,18 @@ class Plugins:
                         settings.INTEGRATION_APPS_PATHS += [plugin_path]
                         apps_changed = True
 
+            # if apps were changed or force loading base apps -> reload
             if apps_changed or force_reload:
-                # if apps were changed or force loading base apps -> reload
+                # first startup or force loading of base apps -> registry is prob false
                 if self.apps_loading or force_reload:
-                    # first startup or force loading of base apps -> registry is prob false
                     self.apps_loading = False
                     self._reload_apps(force_reload=True)
+
                 self._reload_apps()
+
                 # rediscover models/ admin sites
                 self._reregister_contrib_apps()
+
                 # update urls - must be last as models must be registered for creating admin routes
                 self._update_urls()
 

From b1fbac925d103539c56a5c57e29407f60c081337 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:26:41 +0100
Subject: [PATCH 417/493] move stacks to registry

---
 InvenTree/InvenTree/settings.py                |  3 ---
 InvenTree/plugin/helpers.py                    |  8 +++++---
 InvenTree/plugin/registry.py                   | 17 +++++++++++------
 InvenTree/plugin/templatetags/plugin_extras.py |  2 +-
 4 files changed, 17 insertions(+), 13 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 9c1c14ce31..f02d832725 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -884,9 +884,6 @@ if DEBUG or TESTING:
 PLUGINS = []
 INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
-INTEGRATION_APPS_PATHS = []         # Holds all added plugin_paths
-INTEGRATION_ERRORS = {}              # Holds discovering errors
-
 # Test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
 PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)
diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 55d3305da8..6e09130d93 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -10,12 +10,14 @@ from django.conf import settings
 
 # region logging / errors
 def log_plugin_error(error, reference: str = 'general'):
+    from plugin import plugin_reg
+
     # make sure the registry is set up
-    if reference not in settings.INTEGRATION_ERRORS:
-        settings.INTEGRATION_ERRORS[reference] = []
+    if reference not in plugin_reg.errors:
+        plugin_reg.errors[reference] = []
 
     # add error to stack
-    settings.INTEGRATION_ERRORS[reference].append(error)
+    plugin_reg.errors[reference].append(error)
 
 
 class IntegrationPluginError(Exception):
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index da6659fffb..ca0bb1876f 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -41,7 +41,12 @@ class Plugins:
 
         # flags
         self.is_loading = False
-        self.apps_loading = True     # Marks if apps were reloaded yet
+        self.apps_loading = True        # Marks if apps were reloaded yet
+
+        # integration specific
+        self.installed_apps = []         # Holds all added plugin_paths
+
+        self.errors = {}                 # Holds discovering errors
 
     # region public plugin functions
     def load_plugins(self):
@@ -265,7 +270,7 @@ class Plugins:
                     plugin_path = self._get_plugin_path(plugin)
                     if plugin_path not in settings.INSTALLED_APPS:
                         settings.INSTALLED_APPS += [plugin_path]
-                        settings.INTEGRATION_APPS_PATHS += [plugin_path]
+                        self.installed_apps += [plugin_path]
                         apps_changed = True
 
             # if apps were changed or force loading base apps -> reload
@@ -288,7 +293,7 @@ class Plugins:
         this is needed if plugins were loaded earlier and then reloaded as models and admins rely on imports
         those register models and admin in their respective objects (e.g. admin.site for admin)
         """
-        for plugin_path in settings.INTEGRATION_APPS_PATHS:
+        for plugin_path in self.installed_apps:
             try:
                 app_name = plugin_path.split('.')[-1]
                 app_config = apps.get_app_config(app_name)
@@ -332,7 +337,7 @@ class Plugins:
     def deactivate_integration_app(self):
         """deactivate integration app - some magic required"""
         # unregister models from admin
-        for plugin_path in settings.INTEGRATION_APPS_PATHS:
+        for plugin_path in self.installed_apps:
             models = []  # the modelrefs need to be collected as poping an item in a iter is not welcomed
             app_name = plugin_path.split('.')[-1]
             try:
@@ -370,11 +375,11 @@ class Plugins:
         self._update_urls()
 
     def _clean_installed_apps(self):
-        for plugin in settings.INTEGRATION_APPS_PATHS:
+        for plugin in self.installed_apps:
             if plugin in settings.INSTALLED_APPS:
                 settings.INSTALLED_APPS.remove(plugin)
 
-        settings.INTEGRATION_APPS_PATHS = []
+        self.installed_apps = []
 
     def _clean_registry(self):
         # remove all plugins from registry
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 140d2d5298..2d288647ac 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs):
 @register.simple_tag()
 def plugin_errors(*args, **kwargs):
     """Return all plugin errors"""
-    return djangosettings.INTEGRATION_ERRORS
+    return plugin_reg.errors

From 71f74f9cc41877aac576941e532d86f1c67f9604 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:39:24 +0100
Subject: [PATCH 418/493] move globalsettings mixin reg to registry

---
 InvenTree/InvenTree/settings.py                | 1 -
 InvenTree/plugin/registry.py                   | 8 +++++---
 InvenTree/plugin/templatetags/plugin_extras.py | 2 +-
 InvenTree/plugin/test_plugin.py                | 5 ++++-
 4 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index f02d832725..724ff26c7c 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -882,7 +882,6 @@ if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
 PLUGINS = []
-INTEGRATION_PLUGIN_GLOBALSETTING = {}
 
 # Test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index ca0bb1876f..bea13e032b 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -45,6 +45,8 @@ class Plugins:
 
         # integration specific
         self.installed_apps = []         # Holds all added plugin_paths
+        # mixins
+        self.mixins_globalsettings = {}
 
         self.errors = {}                 # Holds discovering errors
 
@@ -228,7 +230,7 @@ class Plugins:
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('globalsettings'):
                     plugin_setting = plugin.globalsettingspatterns
-                    settings.INTEGRATION_PLUGIN_GLOBALSETTING[slug] = plugin_setting
+                    self.mixins_globalsettings[slug] = plugin_setting
 
                     # Add to settings dir
                     InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
@@ -238,7 +240,7 @@ class Plugins:
 
         # collect all settings
         plugin_settings = {}
-        for _, plugin_setting in settings.INTEGRATION_PLUGIN_GLOBALSETTING.items():
+        for _, plugin_setting in self.mixins_globalsettings.items():
             plugin_settings.update(plugin_setting)
 
         # remove settings
@@ -246,7 +248,7 @@ class Plugins:
             InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
 
         # clear cache
-        settings.INTEGRATION_PLUGIN_GLOBALSETTING = {}
+        self.mixins_globalsettings = {}
     # endregion
 
     # region integration_app
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 2d288647ac..1b4b269844 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -28,7 +28,7 @@ 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 djangosettings.INTEGRATION_PLUGIN_GLOBALSETTING.get(plugin)
+    return plugin_reg.mixins_globalsettings.get(plugin)
 
 
 @register.simple_tag()
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index d127bd5be8..b3205ec16c 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -62,7 +62,10 @@ class PluginTagTests(TestCase):
 
     def test_tag_plugin_globalsettings(self):
         """check all plugins are listed"""
-        self.assertEqual(plugin_tags.plugin_globalsettings(self.sample), settings.INTEGRATION_PLUGIN_GLOBALSETTING.get(self.sample))
+        self.assertEqual(
+            plugin_tags.plugin_globalsettings(self.sample),
+            plugin_reg.mixins_globalsettings.get(self.sample)
+        )
 
     def test_tag_mixin_enabled(self):
         """check that mixin enabled functions work"""

From 06e5430948840d9aa136419e97706a38f3ced519 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 18:40:46 +0100
Subject: [PATCH 419/493] refactor

---
 InvenTree/plugin/registry.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index bea13e032b..4a78debaa9 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -39,6 +39,8 @@ class Plugins:
         self.plugins = {}
         self.plugins_inactive = {}
 
+        self.errors = {}                 # Holds discovering errors
+
         # flags
         self.is_loading = False
         self.apps_loading = True        # Marks if apps were reloaded yet
@@ -48,7 +50,6 @@ class Plugins:
         # mixins
         self.mixins_globalsettings = {}
 
-        self.errors = {}                 # Holds discovering errors
 
     # region public plugin functions
     def load_plugins(self):

From 7a65520252d8f4c969d4b5b5729a642fe1f3e8b7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 19:25:40 +0100
Subject: [PATCH 420/493] move import of integration plugins into registry

---
 InvenTree/InvenTree/settings.py |  2 --
 InvenTree/plugin/plugins.py     | 16 ++++------------
 InvenTree/plugin/registry.py    | 14 ++++++++------
 3 files changed, 12 insertions(+), 20 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 724ff26c7c..030ea1d5ae 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -881,8 +881,6 @@ if not TESTING:
 if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
-PLUGINS = []
-
 # Test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
 PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 2827ec590c..79971e49a6 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -81,7 +81,7 @@ def get_plugins(pkg, baseclass, recursive: bool = False):
     return plugins
 
 
-def load_plugins(name: str, cls, module=None):
+def load_plugins(name: str, cls, module):
     """general function to load a plugin class
 
     :param name: name of the plugin for logs
@@ -89,10 +89,9 @@ def load_plugins(name: str, cls, module=None):
     :param module: module from which the plugins should be loaded
     :return: class of the to-be-loaded plugin
     """
-
     logger.debug("Loading %s plugins", name)
 
-    plugins = get_plugins(module, cls) if module else settings.PLUGINS
+    plugins = get_plugins(module, cls)
 
     if len(plugins) > 0:
         logger.info("Discovered %i %s plugins:", len(plugins), name)
@@ -107,14 +106,7 @@ def load_action_plugins():
     """
     Return a list of all registered action plugins
     """
-    return load_plugins('action', ActionPlugin, module=action)
-
-
-def load_integration_plugins():
-    """
-    Return a list of all registered integration plugins
-    """
-    return load_plugins('integration', IntegrationPluginBase)
+    return load_plugins('action', ActionPlugin, action)
 
 
 def load_barcode_plugins():
@@ -124,4 +116,4 @@ def load_barcode_plugins():
     from barcodes import plugins as BarcodePlugins
     from barcodes.barcode import BarcodePlugin
 
-    return load_plugins('barcode', BarcodePlugin, module=BarcodePlugins)
+    return load_plugins('barcode', BarcodePlugin, BarcodePlugins)
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 4a78debaa9..d026662f7c 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -39,6 +39,8 @@ class Plugins:
         self.plugins = {}
         self.plugins_inactive = {}
 
+        self.plugin_modules = []         # Holds all discovered plugins
+
         self.errors = {}                 # Holds discovering errors
 
         # flags
@@ -125,7 +127,7 @@ class Plugins:
         for plugin in settings.PLUGIN_DIRS:
             modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
             if modules:
-                [settings.PLUGINS.append(item) for item in modules]
+                [self.plugin_modules.append(item) for item in modules]
 
         # check if 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):
@@ -133,11 +135,11 @@ class Plugins:
             for entry in metadata.entry_points().get('inventree_plugins', []):
                 plugin = entry.load()
                 plugin.is_package = True
-                settings.PLUGINS.append(plugin)
+                self.plugin_modules.append(plugin)
 
-        # Log found plugins
-        logger.info(f'Found {len(settings.PLUGINS)} plugins!')
-        logger.info(", ".join([a.__module__ for a in settings.PLUGINS]))
+        # Log collected plugins
+        logger.info(f'Collected {len(self.plugin_modules)} plugins!')
+        logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
 
     def _init_plugins(self, disabled=None):
         """initialise all found plugins
@@ -151,7 +153,7 @@ class Plugins:
 
         logger.info('Starting plugin initialisation')
         # Initialize integration plugins
-        for plugin in inventree_plugins.load_integration_plugins():
+        for plugin in self.plugin_modules:
             # check if package
             was_packaged = getattr(plugin, 'is_package', False)
 

From e7babfbb7c8b624a85b2acc30d44004887959e02 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 19:27:53 +0100
Subject: [PATCH 421/493] remove invalid tests

---
 InvenTree/plugin/test_plugin.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index b3205ec16c..e961df0d0b 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -7,7 +7,7 @@ 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_integration_plugins  # , load_action_plugins, load_barcode_plugins
+# from plugin.plugins import load_action_plugins, load_barcode_plugins
 import plugin.templatetags.plugin_extras as plugin_tags
 from plugin import plugin_reg
 
@@ -39,14 +39,14 @@ class PluginIntegrationTests(TestCase):
 
     def test_plugin_loading(self):
         """check if plugins load as expected"""
-        plugin_names_integration = [a().plugin_name() for a in load_integration_plugins()]
         # plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()]  # TODO refactor barcode plugin to support standard loading
         # plugin_names_action = [a().plugin_name() for a in load_action_plugins()]  # TODO refactor action plugin to support standard loading
 
-        self.assertListEqual(sorted(list(set(plugin_names_integration))), sorted(['NoIntegrationPlugin', 'WrongIntegrationPlugin', 'SampleIntegrationPlugin']))
         # self.assertEqual(plugin_names_action, '')
         # self.assertEqual(plugin_names_barcode, '')
 
+        # TODO remove test once loading is moved
+
 
 class PluginTagTests(TestCase):
     """ Tests for the plugin extras """

From 33bc77e1387697875da32e44eda87d50c489cef9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 19:46:38 +0100
Subject: [PATCH 422/493] small docstring changes

---
 InvenTree/InvenTree/settings.py | 9 +++++----
 InvenTree/plugin/registry.py    | 2 +-
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 030ea1d5ae..392f7aa84e 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -875,12 +875,13 @@ MAINTENANCE_MODE_RETRY_AFTER = 60
 PLUGIN_DIRS = ['plugin.builtin', ]
 
 if not TESTING:
+    # load local deploy directory in prod
     PLUGIN_DIRS.append('plugins')
 
-# load samples if in debug mode
 if DEBUG or TESTING:
+    # load samples in debug mode
     PLUGIN_DIRS.append('plugin.samples')
 
-# Test settings
-PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # used to force enable everything plugin
-PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)
+# Plugin test settings
+PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # are plugins beeing tested?
+PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)  # load plugins from setup hooks in testing?
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index d026662f7c..5fdb86f359 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -129,7 +129,7 @@ class Plugins:
             if modules:
                 [self.plugin_modules.append(item) for item in modules]
 
-        # check if 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', []):

From 65226bad1d49765ba7435912bc69563989f7a30e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 20:00:35 +0100
Subject: [PATCH 423/493] add template tag tests

---
 InvenTree/plugin/test_plugin.py | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index e961df0d0b..8c0e7c1450 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -60,6 +60,10 @@ class PluginTagTests(TestCase):
         """test that all plugins are listed"""
         self.assertEqual(plugin_tags.plugin_list(), plugin_reg.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)
+
     def test_tag_plugin_globalsettings(self):
         """check all plugins are listed"""
         self.assertEqual(
@@ -76,3 +80,14 @@ class PluginTagTests(TestCase):
         self.assertEqual(plugin_tags.mixin_enabled(self.plugin_wrong, key), False)
         # mxixn not existing
         self.assertEqual(plugin_tags.mixin_enabled(self.plugin_no, key), False)
+
+    def test_tag_safe_url(self):
+        """test that the safe url tag works expected"""
+        # right url
+        self.assertEqual(plugin_tags.safe_url('index'), '/index')
+        # wrong url
+        self.assertEqual(plugin_tags.safe_url('indexas'), None)
+
+    def test_tag_plugin_errors(self):
+        """test that all errors are listed"""
+        self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors)

From d17af9eae7df85839344692581092b5eb7a5bb4f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 21:03:51 +0100
Subject: [PATCH 424/493] PEP fixes

---
 InvenTree/plugin/apps.py        | 1 -
 InvenTree/plugin/loader.py      | 2 --
 InvenTree/plugin/models.py      | 1 -
 InvenTree/plugin/plugins.py     | 4 ----
 InvenTree/plugin/registry.py    | 1 -
 InvenTree/plugin/test_plugin.py | 1 -
 InvenTree/plugin/urls.py        | 1 -
 7 files changed, 11 deletions(-)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 2b9f3e1d8b..78c3b9b93f 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -2,7 +2,6 @@
 from __future__ import unicode_literals
 
 from django.apps import AppConfig
-from django.conf import settings
 
 from plugin.registry import plugins
 
diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py
index 04b6fc8b8a..2491336a51 100644
--- a/InvenTree/plugin/loader.py
+++ b/InvenTree/plugin/loader.py
@@ -1,8 +1,6 @@
 """
 load templates for loaded plugins
 """
-from django.conf import settings
-
 from django.template.loaders.filesystem import Loader as FilesystemLoader
 from pathlib import Path
 
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index f12a69ecac..19aacad7b7 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -8,7 +8,6 @@ from __future__ import unicode_literals
 from django.utils.translation import gettext_lazy as _
 from django.db import models
 from django.apps import apps
-from django.conf import settings
 
 from plugin import plugin_reg
 
diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 79971e49a6..82256b9201 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -6,16 +6,12 @@ import importlib
 import pkgutil
 import logging
 
-from django.conf import settings
 from django.core.exceptions import AppRegistryNotReady
 
 # Action plugins
 import plugin.builtin.action as action
 from plugin.action import ActionPlugin
 
-# Integration
-from .integration import IntegrationPluginBase
-
 
 logger = logging.getLogger("inventree")
 
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 5fdb86f359..06298613a9 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -52,7 +52,6 @@ class Plugins:
         # mixins
         self.mixins_globalsettings = {}
 
-
     # region public plugin functions
     def load_plugins(self):
         """load and activate all IntegrationPlugins"""
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 8c0e7c1450..646d507b75 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -1,7 +1,6 @@
 """ Unit tests for plugins """
 
 from django.test import TestCase
-from django.conf import settings
 
 import plugin.plugin
 import plugin.integration
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index cf55e8d6e8..d5003701cb 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -1,7 +1,6 @@
 """
 URL lookup for plugin app
 """
-from django.conf import settings
 from django.conf.urls import url, include
 
 from plugin.helpers import get_plugin_error

From 2f739bfbfadc736192cd3425ffb22e939a852743 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 21:10:20 +0100
Subject: [PATCH 425/493] fix test assertation

---
 InvenTree/plugin/test_plugin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 646d507b75..3e0a1967db 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -83,7 +83,7 @@ class PluginTagTests(TestCase):
     def test_tag_safe_url(self):
         """test that the safe url tag works expected"""
         # right url
-        self.assertEqual(plugin_tags.safe_url('index'), '/index')
+        self.assertEqual(plugin_tags.safe_url('api-plugin-install'), '/api/plugin/install/')
         # wrong url
         self.assertEqual(plugin_tags.safe_url('indexas'), None)
 

From 39648e545cfb3b86e5854be961c6258cfdb84ba9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 23:18:45 +0100
Subject: [PATCH 426/493] Add testing to detecte loops Fixes #2308

---
 InvenTree/InvenTree/settings.py | 1 +
 InvenTree/plugin/registry.py    | 8 ++++++++
 2 files changed, 9 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 392f7aa84e..5940887e3d 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -885,3 +885,4 @@ if DEBUG or TESTING:
 # Plugin test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # are plugins beeing tested?
 PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)  # load plugins from setup hooks in testing?
+PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5 if not TESTING else 1)  # how often should plugin loading be tried?
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 06298613a9..f0e5ce8055 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -65,6 +65,7 @@ class Plugins:
 
         registered_sucessfull = False
         blocked_plugin = None
+        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
@@ -84,6 +85,13 @@ class Plugins:
                 self._clean_installed_apps()
                 self._activate_plugins(force_reload=True)
 
+                # we do not want to end in an endless loop
+                retry_counter -=1
+                if retry_counter <= 0:
+                    if settings.PLUGIN_TESTING:
+                        print('Max retries, breaking loading')
+                    break
+
                 # now the loading will re-start up with init
 
         # remove maintenance

From ad768126227b8436088a4d4d065ec3675105240b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 20 Nov 2021 23:24:03 +0100
Subject: [PATCH 427/493] PEP fix

---
 InvenTree/plugin/registry.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index f0e5ce8055..63ead808cb 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -86,7 +86,7 @@ class Plugins:
                 self._activate_plugins(force_reload=True)
 
                 # we do not want to end in an endless loop
-                retry_counter -=1
+                retry_counter -= 1
                 if retry_counter <= 0:
                     if settings.PLUGIN_TESTING:
                         print('Max retries, breaking loading')

From 170e0e45e3cb410ee5f7549f34ae115cdd69795c Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 00:04:41 +0100
Subject: [PATCH 428/493] disable plugin testing by default

---
 InvenTree/InvenTree/settings.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 5940887e3d..4e086e034b 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -883,6 +883,6 @@ if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
 # Plugin test settings
-PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # are plugins beeing tested?
+PLUGIN_TESTING = get_setting('PLUGIN_TESTING', False)  # are plugins beeing tested?
 PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)  # load plugins from setup hooks in testing?
 PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5 if not TESTING else 1)  # how often should plugin loading be tried?

From be5289ba0f788ecac8c344a0b50010016c95f0f7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 00:53:04 +0100
Subject: [PATCH 429/493] break on database error

---
 InvenTree/plugin/registry.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 63ead808cb..1b693c8c5b 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -75,6 +75,7 @@ class Plugins:
             except (OperationalError, ProgrammingError):
                 # Exception if the database has not been migrated yet
                 logger.info('Database not accessible while loading plugins')
+                break
             except IntegrationPluginError as error:
                 logger.error(f'Encountered an error with {error.path}:\n{error.message}')
                 log_plugin_error({error.path: error.message}, 'load')

From e1dd7a17f2ddeddeb0616441313d6537ae020289 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 00:56:00 +0100
Subject: [PATCH 430/493] use testing by default

---
 InvenTree/InvenTree/settings.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 4e086e034b..5940887e3d 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -883,6 +883,6 @@ if DEBUG or TESTING:
     PLUGIN_DIRS.append('plugin.samples')
 
 # Plugin test settings
-PLUGIN_TESTING = get_setting('PLUGIN_TESTING', False)  # are plugins beeing tested?
+PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # are plugins beeing tested?
 PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)  # load plugins from setup hooks in testing?
 PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5 if not TESTING else 1)  # how often should plugin loading be tried?

From c0e45d7b4fe060b0cf3f957114d757c65b66500b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 01:16:12 +0100
Subject: [PATCH 431/493] remove url check wrapper will be a seperate PR later

---
 InvenTree/plugin/urls.py | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index d5003701cb..c971e920b9 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -18,14 +18,3 @@ def get_plugin_urls():
             urls.append(plugin.urlpatterns)
     # TODO wrap everything in plugin_url_wrapper
     return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
-
-
-def plugin_url_wrapper(view):
-    """wrapper to catch errors and log them to plugin error stack"""
-    def f(request, *args, **kwargs):
-        try:
-            return view(request, *args, **kwargs)
-        except Exception as error:
-            get_plugin_error(error, do_log=True, log_name='view')
-            # TODO disable if in production
-    return f

From cecee032d755ee80fb9db419f65b5387387f9b3b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 01:21:25 +0100
Subject: [PATCH 432/493] streamline html templates

---
 InvenTree/templates/503.html       | 70 +++++++++++++++++++++++++++---
 InvenTree/templates/auth_base.html | 69 -----------------------------
 2 files changed, 63 insertions(+), 76 deletions(-)
 delete mode 100644 InvenTree/templates/auth_base.html

diff --git a/InvenTree/templates/503.html b/InvenTree/templates/503.html
index 8cef12212d..fbee60c694 100644
--- a/InvenTree/templates/503.html
+++ b/InvenTree/templates/503.html
@@ -1,17 +1,73 @@
-{% extends "auth_base.html" %}
+{% extends "skeleton.html" %}
+{% load static %}
 {% load i18n %}
 
-
 {% block head %}
 <meta http-equiv="refresh" content="30">
 {% endblock %}
 
 {% block page_title %}
-    {% trans 'Site is in Maintenance' %}
+{% trans 'Site is in Maintenance' %}
 {% endblock %}
 
-{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}
+{% block body_class %}login-screen{% endblock %}
 
-{% block content %}
-{% trans 'The site is currently in maintenance and should be up again soon!' %}
-{% endblock %}
\ No newline at end of file
+{% block body %}
+    <!-- 
+        Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
+    -->
+    <div class='container-fluid'>
+        <div class='notification-area' id='alerts'>
+            <!-- Div for displayed alerts -->
+        </div>
+    </div>
+
+    <div class='main body-wrapper login-screen d-flex'>
+
+
+        <div class='login-container'>
+        <div class="row">
+            <div class='container-fluid'>
+
+                <div class='clearfix content-heading login-header d-flex flex-wrap'>
+                    <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
+                    {% include "spacer.html" %}
+                    <span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock %}</h3></span>
+                </div>
+            </div>
+                <div class='container-fluid'>
+                    <hr>
+                    {% block content %}
+                    {% trans 'The site is currently in maintenance and should be up again soon!' %}
+                    {% endblock %}
+                </div>
+        </div>
+        </div>
+
+        {% block extra_body %}
+        {% endblock %}
+    </div>
+{% endblock %}
+
+{% block js_base %}
+<script type='text/javascript'>
+$(document).ready(function () {
+    // notifications
+    {% if messages %}
+    {% for message in messages %}
+    showAlertOrCache(
+        '{{ message }}',
+        true,
+        {
+            style: 'info',
+        }
+    );
+    {% endfor %}
+    {% endif %}
+
+    inventreeDocReady();
+});
+</script>
+{% endblock %}
+</body>
+</html>
diff --git a/InvenTree/templates/auth_base.html b/InvenTree/templates/auth_base.html
deleted file mode 100644
index 603b417e78..0000000000
--- a/InvenTree/templates/auth_base.html
+++ /dev/null
@@ -1,69 +0,0 @@
-{% extends "skeleton.html" %}
-{% load static %}
-{% load i18n %}
-{% load inventree_extras %}
-
-{% block page_title %}
-{% inventree_title %} | {% block head_title %}{% endblock %}
-{% endblock %}
-
-{% block body_class %}login-screen{% endblock %}
-
-{% block body %}
-    <!-- 
-        Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
-    -->
-    <div class='container-fluid'>
-        <div class='notification-area' id='alerts'>
-            <!-- Div for displayed alerts -->
-        </div>
-    </div>
-
-    <div class='main body-wrapper login-screen d-flex'>
-
-
-        <div class='login-container'>
-        <div class="row">
-            <div class='container-fluid'>
-
-                <div class='clearfix content-heading login-header d-flex flex-wrap'>
-                    <img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
-                    {% include "spacer.html" %}
-                    <span class='float-right'><h3>{% block body_title %}{% inventree_title %}{% endblock %}</h3></span>
-                </div>
-            </div>
-                <div class='container-fluid'>
-                    <hr>
-                    {% block content %}
-                    {% endblock %}
-                </div>
-        </div>
-        </div>
-
-        {% block extra_body %}
-        {% endblock %}
-    </div>
-{% endblock %}
-
-{% block js_base %}
-<script type='text/javascript'>
-$(document).ready(function () {
-    // notifications
-    {% if messages %}
-    {% for message in messages %}
-    showAlertOrCache(
-        '{{ message }}',
-        true,
-        {
-            style: 'info',
-        }
-    );
-    {% endfor %}
-    {% endif %}
-
-    inventreeDocReady();
-});
-</script>
-{% endblock %}
-</body>
-</html>
\ No newline at end of file

From 046ee7df06e277e63efb12bd7594f3933b05ff96 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 01:57:46 +0100
Subject: [PATCH 433/493] add api test

---
 InvenTree/plugin/serializers.py |  1 +
 InvenTree/plugin/test_api.py    | 55 +++++++++++++++++++++++++++++++++
 2 files changed, 56 insertions(+)
 create mode 100644 InvenTree/plugin/test_api.py

diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index 6e19eb2559..e25e253498 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -108,6 +108,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
         try:
             result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR))
             ret['result'] = str(result, 'utf-8')
+            ret['success'] = True
         except subprocess.CalledProcessError as error:
             ret['result'] = str(error.output, 'utf-8')
             ret['error'] = True
diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
new file mode 100644
index 0000000000..ee99bbfadd
--- /dev/null
+++ b/InvenTree/plugin/test_api.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.urls import reverse
+
+from InvenTree.api_tester import InvenTreeAPITestCase
+
+
+class PluginDetailAPITest(InvenTreeAPITestCase):
+    """
+    Tests the plugin AP I endpoints
+    """
+
+    roles = [
+        'admin.add',
+        'admin.view',
+        'admin.change',
+        'admin.delete',
+    ]
+
+    def setUp(self):
+        self.MSG_NO_PKG = 'Either packagenmae of url must be provided'
+
+        self.PKG_NAME = 'minimal'
+        super().setUp()
+
+    def test_plugin_install(self):
+        """
+        Test the plugin install command
+        """
+        url = reverse('api-plugin-install')
+
+        # valid - Pypi
+        data = self.post(url, {
+            'confirm': True,
+            'packagename': self.PKG_NAME
+        }, expected_code=201).data
+
+        self.assertEqual(data['success'], True)
+
+        # invalid tries
+        # no input
+        self.post(url, {}, expected_code=400)
+
+        # no package info
+        data = self.post(url, {
+            'confirm': True,
+        }, expected_code=400).data
+        self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper())
+        self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper())
+
+        # not confirmed
+        data = self.post(url, {
+            'packagename': self.PKG_NAME
+        }, expected_code=400).data

From 78cd10f3b9658dc2ffb28cb45faa004d4da9716a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 02:01:31 +0100
Subject: [PATCH 434/493] PEP fix

---
 InvenTree/plugin/urls.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index c971e920b9..dfcfb79dd0 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -3,7 +3,6 @@ URL lookup for plugin app
 """
 from django.conf.urls import url, include
 
-from plugin.helpers import get_plugin_error
 from plugin import plugin_reg
 
 

From fa8f1bbd6f3512b40ef2bc61432c680ad0c22a69 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 02:13:32 +0100
Subject: [PATCH 435/493] ignore plugins in coverage

---
 setup.cfg | 1 +
 1 file changed, 1 insertion(+)

diff --git a/setup.cfg b/setup.cfg
index c30425b9a1..63d9312c59 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -24,3 +24,4 @@ omit =
     InvenTree/InvenTree/utils.py
     InvenTree/InvenTree/wsgi.py
     InvenTree/users/apps.py
+    InvenTree/plugins

From 290e91ff798e2fe340601bf54d6a692ddc5f3337 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 02:19:14 +0100
Subject: [PATCH 436/493] there are many ways to be broken ...

---
 InvenTree/plugin/samples/integration/broken_file.py   | 11 +++++++++++
 InvenTree/plugin/samples/integration/broken_sample.py |  8 ++++++--
 2 files changed, 17 insertions(+), 2 deletions(-)
 create mode 100644 InvenTree/plugin/samples/integration/broken_file.py

diff --git a/InvenTree/plugin/samples/integration/broken_file.py b/InvenTree/plugin/samples/integration/broken_file.py
new file mode 100644
index 0000000000..a7d115739f
--- /dev/null
+++ b/InvenTree/plugin/samples/integration/broken_file.py
@@ -0,0 +1,11 @@
+"""sample of a broken python file that will be ignored on import"""
+from plugin import IntegrationPluginBase
+
+
+class BrokenIntegrationPlugin(IntegrationPluginBase):
+    """
+    An very broken integration plugin
+    """
+
+
+aaa = bb  # noqa: F821
diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py
index d8cd60a2bc..f7ef92c901 100644
--- a/InvenTree/plugin/samples/integration/broken_sample.py
+++ b/InvenTree/plugin/samples/integration/broken_sample.py
@@ -1,4 +1,4 @@
-"""sample of a broken python file that will be ignroed on import"""
+"""sample of a broken integration plugin"""
 from plugin import IntegrationPluginBase
 
 
@@ -6,6 +6,10 @@ class BrokenIntegrationPlugin(IntegrationPluginBase):
     """
     An very broken integration plugin
     """
+    PLUGIN_TITLE = 'Broken Plugin'
+    PLUGIN_SLUG = 'broken'
 
+    def __init__(self):
+        super().__init__()
 
-aaa = bb  # noqa: F821
+        raise KeyError('This is a dummy error')

From 6b1c436135bf3090a879156c7fb6b727869862b5 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 02:24:08 +0100
Subject: [PATCH 437/493] names of plugins must be unique

---
 InvenTree/plugin/samples/integration/broken_file.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/samples/integration/broken_file.py b/InvenTree/plugin/samples/integration/broken_file.py
index a7d115739f..c575cfb623 100644
--- a/InvenTree/plugin/samples/integration/broken_file.py
+++ b/InvenTree/plugin/samples/integration/broken_file.py
@@ -2,7 +2,7 @@
 from plugin import IntegrationPluginBase
 
 
-class BrokenIntegrationPlugin(IntegrationPluginBase):
+class BrokenFileIntegrationPlugin(IntegrationPluginBase):
     """
     An very broken integration plugin
     """

From ba6a7c05416926578bce4f72b2d2742bebdcf424 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 02:28:01 +0100
Subject: [PATCH 438/493] check confirm is True

---
 InvenTree/plugin/test_api.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index ee99bbfadd..2413e2c882 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -50,6 +50,11 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper())
 
         # not confirmed
-        data = self.post(url, {
+        self.post(url, {
             'packagename': self.PKG_NAME
         }, expected_code=400).data
+        data = self.post(url, {
+            'packagename': self.PKG_NAME,
+            'confirm': False,
+        }, expected_code=400).data
+        self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper())

From 59a1047d4105b94181a75b1d496469f0efe2989f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 18:59:43 +0100
Subject: [PATCH 439/493] add admin action test

---
 InvenTree/plugin/test_api.py | 36 ++++++++++++++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index 2413e2c882..4914cfc53b 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -58,3 +58,39 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
             'confirm': False,
         }, expected_code=400).data
         self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper())
+
+    def test_admin_action(self):
+        """
+        Test the PluginConfig action commands
+        """
+        from plugin.models import PluginConfig
+        from plugin import plugin_reg
+
+        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()
+            fixtures = PluginConfig.objects.all()
+
+        print([str(a) for a in fixtures])
+        fixtures = fixtures.first()
+
+        # deactivate plugin
+        self.post(url, {
+            'action': 'plugin_deactivate',
+            '_selected_action':  [f.pk for f in fixtures],
+        }, expected_code=200)
+
+        # deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered
+        self.post(url, {
+            'action': 'plugin_deactivate',
+            '_selected_action':  [f.pk for f in fixtures],
+        }, expected_code=200)
+
+        # activate plugin
+        self.post(url, {
+            'action': 'plugin_activate',
+            '_selected_action':  [f.pk for f in fixtures],
+        }, expected_code=200)

From 6533457400701831c524a17419f38b3c97983be7 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 19:08:01 +0100
Subject: [PATCH 440/493] always drop out of maintenance on startup

---
 InvenTree/plugin/apps.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 78c3b9b93f..8a3cd97889 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 
 from django.apps import AppConfig
+from maintenance_mode.core import set_maintenance_mode
 
 from plugin.registry import plugins
 
@@ -11,5 +12,10 @@ class PluginAppConfig(AppConfig):
 
     def ready(self):
         if not plugins.is_loading:
+            # this is the first startup
             plugins.collect_plugins()
             plugins.load_plugins()
+
+            # drop out of maintenance
+            # makes sure we did not have an error in reloading and maintenance is still active
+            set_maintenance_mode(False)

From 6b7ea10ba223b9e4d6707a32bcd4a7862ddcb749 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 19:18:37 +0100
Subject: [PATCH 441/493] PEP fix

---
 InvenTree/plugin/test_api.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index 4914cfc53b..2a3f7531ff 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -80,17 +80,17 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         # deactivate plugin
         self.post(url, {
             'action': 'plugin_deactivate',
-            '_selected_action':  [f.pk for f in fixtures],
+            '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)
 
         # deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered
         self.post(url, {
             'action': 'plugin_deactivate',
-            '_selected_action':  [f.pk for f in fixtures],
+            '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)
 
         # activate plugin
         self.post(url, {
             'action': 'plugin_activate',
-            '_selected_action':  [f.pk for f in fixtures],
+            '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)

From 4e6e87d950fb93f7f296ed6bfbc600a34a54f51b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 19:19:44 +0100
Subject: [PATCH 442/493] fix test limitition

---
 InvenTree/plugin/test_api.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index 2a3f7531ff..9bd35f9555 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -75,8 +75,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
             fixtures = PluginConfig.objects.all()
 
         print([str(a) for a in fixtures])
-        fixtures = fixtures.first()
-
+        fixtures = fixtures[0:1]
         # deactivate plugin
         self.post(url, {
             'action': 'plugin_deactivate',

From 211a8e27e6adf4d0f60d346af0d9d1aa9e5290b4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 20:13:58 +0100
Subject: [PATCH 443/493] use pluginreg to reload everywhere

---
 InvenTree/plugin/admin.py  | 5 ++---
 InvenTree/plugin/models.py | 5 ++---
 2 files changed, 4 insertions(+), 6 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index beef26a20e..6e200d8aaa 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -5,7 +5,7 @@ from django.contrib import admin
 from django.apps import apps
 
 import plugin.models as models
-
+from plugin import plugin_reg
 
 def plugin_update(queryset, new_status: bool):
     """general function for bulk changing plugins"""
@@ -20,8 +20,7 @@ def plugin_update(queryset, new_status: bool):
 
     # reload plugins if they changed
     if apps_changed:
-        app = apps.get_app_config('plugin')
-        app.reload_plugins()
+        plugin_reg.reload_plugins()
 
 
 @admin.action(description='Activate plugin(s)')
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 19aacad7b7..f3990c58c1 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -83,13 +83,12 @@ class PluginConfig(models.Model):
         reload = kwargs.pop('no_reload', False)  # check if no_reload flag is set
 
         ret = super().save(force_insert, force_update, *args, **kwargs)
-        app = apps.get_app_config('plugin')
 
         if not reload:
             if self.active is False and self.__org_active is True:
-                app.reload_plugins()
+                plugin_reg.reload_plugins()
 
             elif self.active is True and self.__org_active is False:
-                app.reload_plugins()
+                plugin_reg.reload_plugins()
 
         return ret

From 75a8b88a92dc7e11e41dba367c7f06673f2d91e4 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 20:14:17 +0100
Subject: [PATCH 444/493] now it should test

---
 InvenTree/plugin/test_api.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index 9bd35f9555..da17ee62b0 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -78,18 +78,21 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         fixtures = fixtures[0:1]
         # deactivate plugin
         self.post(url, {
-            'action': 'plugin_deactivate',
+            'action': 'plugin_deactivate', 
+            'index': 0,
             '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)
 
         # deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered
         self.post(url, {
             'action': 'plugin_deactivate',
+            'index': 0,
             '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)
 
         # activate plugin
         self.post(url, {
             'action': 'plugin_activate',
+            'index': 0,
             '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)

From bafbebb63431ba62430216e49eba38d05d34e481 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 20:22:44 +0100
Subject: [PATCH 445/493] test plugin save action

---
 InvenTree/plugin/test_api.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index da17ee62b0..13ac6b3ac0 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -96,3 +96,8 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)
+
+        # save to deactivate plugin
+        self.post(reverse('admin:plugin_pluginconfig_change', {'pk': fixtures[0].pk}), {
+            '_save': 'Save',
+        }, expected_code=200)

From 03e5279ec037c5a2b69f9dc62e804a0dd83a797a Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 20:23:43 +0100
Subject: [PATCH 446/493] PEP fixes

---
 InvenTree/plugin/admin.py    | 2 +-
 InvenTree/plugin/models.py   | 1 -
 InvenTree/plugin/test_api.py | 2 +-
 3 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 6e200d8aaa..3a96a4b9ea 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -2,11 +2,11 @@
 from __future__ import unicode_literals
 
 from django.contrib import admin
-from django.apps import apps
 
 import plugin.models as models
 from plugin import plugin_reg
 
+
 def plugin_update(queryset, new_status: bool):
     """general function for bulk changing plugins"""
     apps_changed = False
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index f3990c58c1..b8178440af 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -7,7 +7,6 @@ from __future__ import unicode_literals
 
 from django.utils.translation import gettext_lazy as _
 from django.db import models
-from django.apps import apps
 
 from plugin import plugin_reg
 
diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index 13ac6b3ac0..a03427dc2b 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -78,7 +78,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         fixtures = fixtures[0:1]
         # deactivate plugin
         self.post(url, {
-            'action': 'plugin_deactivate', 
+            'action': 'plugin_deactivate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
         }, expected_code=200)

From c828da284ce390436e823e286f51fc8cb2ea9790 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 22:04:22 +0100
Subject: [PATCH 447/493] fix tests to really hit admin actions

---
 InvenTree/plugin/test_api.py | 32 +++++++++++++++++++++++---------
 1 file changed, 23 insertions(+), 9 deletions(-)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index a03427dc2b..a469bfa31e 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -77,27 +77,41 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         print([str(a) for a in fixtures])
         fixtures = fixtures[0:1]
         # deactivate plugin
-        self.post(url, {
+        response =  self.client.post(url, {
             'action': 'plugin_deactivate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
-        }, expected_code=200)
+        }, follow=True)
+        self.assertEqual(response.status_code, 200)
 
         # deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered
-        self.post(url, {
+        response =  self.client.post(url, {
             'action': 'plugin_deactivate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
-        }, expected_code=200)
+        }, follow=True)
+        self.assertEqual(response.status_code, 200)
 
         # activate plugin
-        self.post(url, {
+        response =  self.client.post(url, {
             'action': 'plugin_activate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
-        }, expected_code=200)
+        }, follow=True)
+        self.assertEqual(response.status_code, 200)
 
-        # save to deactivate plugin
-        self.post(reverse('admin:plugin_pluginconfig_change', {'pk': fixtures[0].pk}), {
+        # activate everything
+        fixtures = PluginConfig.objects.all()
+        response =  self.client.post(url, {
+            'action': 'plugin_activate',
+            'index': 0,
+            '_selected_action': [f.pk for f in fixtures],
+        }, follow=True)
+        self.assertEqual(response.status_code, 200)
+
+        fixtures = PluginConfig.objects.filter(active=True)
+        # save to deactivate a plugin
+        response =  self.client.post(reverse('admin:plugin_pluginconfig_change', args=(fixtures.first().pk, )), {
             '_save': 'Save',
-        }, expected_code=200)
+        }, follow=True)
+        self.assertEqual(response.status_code, 200)

From bd67285314476977c9e5166ae4d4384b6b5c9cd8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 22:11:18 +0100
Subject: [PATCH 448/493] PEP fixes

---
 InvenTree/plugin/test_api.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index a469bfa31e..0bb5f7789b 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -77,7 +77,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         print([str(a) for a in fixtures])
         fixtures = fixtures[0:1]
         # deactivate plugin
-        response =  self.client.post(url, {
+        response = self.client.post(url, {
             'action': 'plugin_deactivate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
@@ -85,7 +85,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         self.assertEqual(response.status_code, 200)
 
         # deactivate plugin - deactivate again -> nothing will hapen but the nothing 'changed' function is triggered
-        response =  self.client.post(url, {
+        response = self.client.post(url, {
             'action': 'plugin_deactivate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
@@ -93,7 +93,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         self.assertEqual(response.status_code, 200)
 
         # activate plugin
-        response =  self.client.post(url, {
+        response = self.client.post(url, {
             'action': 'plugin_activate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
@@ -102,7 +102,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
 
         # activate everything
         fixtures = PluginConfig.objects.all()
-        response =  self.client.post(url, {
+        response = self.client.post(url, {
             'action': 'plugin_activate',
             'index': 0,
             '_selected_action': [f.pk for f in fixtures],
@@ -111,7 +111,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
 
         fixtures = PluginConfig.objects.filter(active=True)
         # save to deactivate a plugin
-        response =  self.client.post(reverse('admin:plugin_pluginconfig_change', args=(fixtures.first().pk, )), {
+        response = self.client.post(reverse('admin:plugin_pluginconfig_change', args=(fixtures.first().pk, )), {
             '_save': 'Save',
         }, follow=True)
         self.assertEqual(response.status_code, 200)

From 7782a22f3843040ad3dbaed0dd7b9185ef314184 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 23:46:54 +0100
Subject: [PATCH 449/493] make plugin init safe

---
 InvenTree/plugin/registry.py | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 1b693c8c5b..1b770948d2 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -200,7 +200,19 @@ class Plugins:
                 # now we can be sure that an admin has activated the plugin -> 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.
                 logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
-                plugin = plugin()
+                try:
+                    plugin = plugin()
+                except Exception as error:
+                    # log error
+                    get_plugin_error(error, do_log=True, log_name='init')
+
+                    plugin_db_setting.active = False
+                    # TODO save the error to the plugin
+                    plugin_db_setting.save()
+
+                    # add to incative plugins
+                    self.plugins_inactive[plug_key] = plugin_db_setting
+
                 logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged
                 if plugin_db_setting:

From 87947c582d73ca486b6aaafa2e924b55258cf87e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 23:48:33 +0100
Subject: [PATCH 450/493] always log error

---
 InvenTree/plugin/registry.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 1b770948d2..6003316c6e 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -185,11 +185,10 @@ class Plugins:
                     if plugin.__name__ == disabled:
                         # errors are bad so disable the plugin in the database
                         # but only if not in testing mode as that breaks in the GH pipeline
+                        log_plugin_error({plug_key: 'Disabled'}, 'init')
                         if not settings.PLUGIN_TESTING:
                             plugin_db_setting.active = False
                             # TODO save the error to the plugin
-
-                            log_plugin_error({plug_key: 'Disabled'}, 'init')
                             plugin_db_setting.save()
 
                         # add to inactive plugins so it shows up in the ui

From 2e28bb225f79b3b2c8f291b455233d106db4e6a9 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 23:50:54 +0100
Subject: [PATCH 451/493] fix broken integration plugin def

---
 InvenTree/plugin/samples/integration/broken_sample.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py
index f7ef92c901..8901d83dfd 100644
--- a/InvenTree/plugin/samples/integration/broken_sample.py
+++ b/InvenTree/plugin/samples/integration/broken_sample.py
@@ -6,6 +6,7 @@ class BrokenIntegrationPlugin(IntegrationPluginBase):
     """
     An very broken integration plugin
     """
+    PLUGIN_NAME = 'Test'
     PLUGIN_TITLE = 'Broken Plugin'
     PLUGIN_SLUG = 'broken'
 

From f71b40e03117ab3c7785ec1e04bc970ba4e49c83 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 23:51:36 +0100
Subject: [PATCH 452/493] also handle errors on internal plugins

---
 InvenTree/plugin/helpers.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 6e09130d93..33259dadf4 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -32,7 +32,10 @@ class IntegrationPluginError(Exception):
 def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_name: str = ''):
     package_path = traceback.extract_tb(error.__traceback__)[-1].filename
     install_path = sysconfig.get_paths()["purelib"]
-    package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
+    try:
+        package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
+    except ValueError:
+        package_name = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
 
     if do_log:
         log_kwargs = {}

From 8e7c96626fbe52c11675cc1591b612b8d8dd0ae2 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sun, 21 Nov 2021 23:57:45 +0100
Subject: [PATCH 453/493] that statement is quite important

---
 InvenTree/plugin/registry.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 6003316c6e..5db2bf0aca 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -211,6 +211,7 @@ class Plugins:
 
                     # add to incative plugins
                     self.plugins_inactive[plug_key] = plugin_db_setting
+                    continue  # continue -> the plugin is not loaded
 
                 logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged

From c3e4a5602170871ad846432a456b2aa95ab1db3e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:16:09 +0100
Subject: [PATCH 454/493] always reset plugin modules on collection

---
 InvenTree/plugin/registry.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 5db2bf0aca..9fcdb21a35 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -131,6 +131,8 @@ class Plugins:
     # region general plugin managment mechanisms
     def collect_plugins(self):
         """collect integration plugins from all possible ways of loading"""
+        self.plugin_modules = []  # clear
+
         # Collect plugins from paths
         for plugin in settings.PLUGIN_DIRS:
             modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)

From a1b821bf780c1eff4ba8bf7c3a1cb4ce30ad9183 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:16:43 +0100
Subject: [PATCH 455/493] just use the default failing mechanism

---
 InvenTree/plugin/registry.py | 12 ++----------
 1 file changed, 2 insertions(+), 10 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 9fcdb21a35..39179af410 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -204,16 +204,8 @@ class Plugins:
                 try:
                     plugin = plugin()
                 except Exception as error:
-                    # log error
-                    get_plugin_error(error, do_log=True, log_name='init')
-
-                    plugin_db_setting.active = False
-                    # TODO save the error to the plugin
-                    plugin_db_setting.save()
-
-                    # add to incative plugins
-                    self.plugins_inactive[plug_key] = plugin_db_setting
-                    continue  # continue -> the plugin is not loaded
+                    # log error and raise it -> disable plugin
+                    get_plugin_error(error, do_raise=True, do_log=True, log_name='init')
 
                 logger.info(f'Loaded integration plugin {plugin.slug}')
                 plugin.is_package = was_packaged

From adc058c8b431a5be5f4696c6e188f6aacfb145a8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:17:04 +0100
Subject: [PATCH 456/493] only reload once - even if forced

---
 InvenTree/plugin/registry.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 39179af410..eca2757c08 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -298,8 +298,8 @@ class Plugins:
                 if self.apps_loading or force_reload:
                     self.apps_loading = False
                     self._reload_apps(force_reload=True)
-
-                self._reload_apps()
+                else:
+                    self._reload_apps()
 
                 # rediscover models/ admin sites
                 self._reregister_contrib_apps()

From e5d474fa0b4c091ced42f43155b32fbddc2374eb Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:17:35 +0100
Subject: [PATCH 457/493] always set flag

---
 InvenTree/plugin/registry.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index eca2757c08..c6be538d1e 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -418,6 +418,7 @@ class Plugins:
         clear_url_caches()
 
     def _reload_apps(self, force_reload: bool = False):
+        self.is_loading = True  # set flag to disable loop reloading
         if force_reload:
             # we can not use the built in functions as we need to brute force the registry
             apps.app_configs = OrderedDict()
@@ -425,7 +426,6 @@ class Plugins:
             apps.clear_cache()
             self._try_reload(apps.populate, settings.INSTALLED_APPS)
 
-        self.is_loading = True
         self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
         self.is_loading = False
 

From 38eaca1104f1f920df4ff5b1d5f3c37cccda82ed Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:48:46 +0100
Subject: [PATCH 458/493] fix path prefixes

---
 InvenTree/plugin/helpers.py | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 33259dadf4..0de0c0aaec 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -35,7 +35,19 @@ def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_na
     try:
         package_name = pathlib.Path(package_path).relative_to(install_path).parts[0]
     except ValueError:
-        package_name = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
+        # is file - loaded -> form a name for that
+        path_obj = pathlib.Path(package_path).relative_to(settings.BASE_DIR)
+        path_parts = [*path_obj.parts]
+        path_parts[-1] = path_parts[-1].replace(path_obj.suffix, '')  # remove suffix
+
+        # remove path preixes
+        if path_parts[0] == 'plugin':
+            path_parts.remove('plugin')
+            path_parts.pop(0)
+        else:
+            path_parts.remove('plugins')
+
+        package_name = '.'.join(path_parts)
 
     if do_log:
         log_kwargs = {}

From c49607650561e1ba57e21ea896da304da6a1e41d Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:52:42 +0100
Subject: [PATCH 459/493] check if file plugin was disabled

---
 InvenTree/plugin/registry.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index c6be538d1e..2ec7ff9131 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -184,7 +184,8 @@ class Plugins:
             if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
                 # check if the plugin was blocked -> threw an error
                 if disabled:
-                    if plugin.__name__ == disabled:
+                    # option1: package, option2: file-based
+                    if (plugin.__name__ == disabled) or (plugin.__module__==disabled):
                         # errors are bad so disable the plugin in the database
                         # but only if not in testing mode as that breaks in the GH pipeline
                         log_plugin_error({plug_key: 'Disabled'}, 'init')

From 4b98ea27ce881378a42874907485530872afed3b Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 00:52:51 +0100
Subject: [PATCH 460/493] better format

---
 InvenTree/plugin/registry.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 2ec7ff9131..f872a9acd2 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -199,7 +199,8 @@ class Plugins:
                         continue  # continue -> the plugin is not loaded
 
                 # init package
-                # now we can be sure that an admin has activated the plugin -> as of Nov 2021 there are not many checks in place
+                # 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.
                 logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
                 try:

From d54bbf562b63d41519bc6947b790f32bdb177fc0 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 01:06:12 +0100
Subject: [PATCH 461/493] remove redundant loggin

---
 InvenTree/plugin/registry.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index f872a9acd2..8991c9975e 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -188,7 +188,6 @@ class Plugins:
                     if (plugin.__name__ == disabled) or (plugin.__module__==disabled):
                         # errors are bad so disable the plugin in the database
                         # but only if not in testing mode as that breaks in the GH pipeline
-                        log_plugin_error({plug_key: 'Disabled'}, 'init')
                         if not settings.PLUGIN_TESTING:
                             plugin_db_setting.active = False
                             # TODO save the error to the plugin

From 3920108d83a7c2fbcbc9c2201598960413e69dea Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 01:28:36 +0100
Subject: [PATCH 462/493] do not reload whe currently loading

---
 InvenTree/plugin/registry.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 8991c9975e..b79a1f46e6 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -121,6 +121,10 @@ class Plugins:
 
     def reload_plugins(self):
         """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()

From 1efdf16f92026fcc5a035c1b9bc04cb74d1237e1 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 01:30:04 +0100
Subject: [PATCH 463/493] only reload one

---
 InvenTree/plugin/registry.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index b79a1f46e6..6d96018d36 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -430,8 +430,8 @@ class Plugins:
             apps.apps_ready = apps.models_ready = apps.loading = apps.ready = False
             apps.clear_cache()
             self._try_reload(apps.populate, settings.INSTALLED_APPS)
-
-        self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
+        else:
+            self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
         self.is_loading = False
 
     def _try_reload(self, cmd, *args, **kwargs):

From 40dafb7fdaef4a0f2bd7dd9828c1baeb4b018abf Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 01:36:37 +0100
Subject: [PATCH 464/493] PEP fix

---
 InvenTree/plugin/registry.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 6d96018d36..2cdc266a01 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -162,7 +162,6 @@ class Plugins:
         :type disabled: str, optional
         :raises error: IntegrationPluginError
         """
-        from plugin.helpers import log_plugin_error
         from plugin.models import PluginConfig
 
         logger.info('Starting plugin initialisation')
@@ -189,7 +188,7 @@ class Plugins:
                 # check if the plugin was blocked -> threw an error
                 if disabled:
                     # option1: package, option2: file-based
-                    if (plugin.__name__ == disabled) or (plugin.__module__==disabled):
+                    if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
                         # errors are bad so disable the plugin in the database
                         # but only if not in testing mode as that breaks in the GH pipeline
                         if not settings.PLUGIN_TESTING:

From 395573ca5be9c806007689bd09ef4a54bc727559 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 01:55:21 +0100
Subject: [PATCH 465/493] do not trigger reload

---
 InvenTree/plugin/registry.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 2cdc266a01..f0ef035720 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -194,7 +194,7 @@ class Plugins:
                         if not settings.PLUGIN_TESTING:
                             plugin_db_setting.active = False
                             # TODO save the error to the plugin
-                            plugin_db_setting.save()
+                            plugin_db_setting.save(no_reload=True)
 
                         # add to inactive plugins so it shows up in the ui
                         self.plugins_inactive[plug_key] = plugin_db_setting

From 3050bb0703786294329fff84abe9fa628e380bf8 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 02:46:03 +0100
Subject: [PATCH 466/493] higher retry threshold + better logging

---
 InvenTree/InvenTree/settings.py |  2 +-
 InvenTree/plugin/registry.py    | 15 ++++++++-------
 2 files changed, 9 insertions(+), 8 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 5940887e3d..bf9a3bfaac 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -885,4 +885,4 @@ if DEBUG or TESTING:
 # Plugin test settings
 PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING)  # are plugins beeing tested?
 PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False)  # load plugins from setup hooks in testing?
-PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5 if not TESTING else 1)  # how often should plugin loading be tried?
+PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5)  # how often should plugin loading be tried?
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index f0ef035720..aa180170db 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -77,7 +77,7 @@ class Plugins:
                 logger.info('Database not accessible while loading plugins')
                 break
             except IntegrationPluginError as error:
-                logger.error(f'Encountered an error with {error.path}:\n{error.message}')
+                logger.error(f'[PLUGIN] Encountered an error with {error.path}:\n{error.message}')
                 log_plugin_error({error.path: error.message}, 'load')
                 blocked_plugin = error.path  # we will not try to load this app again
 
@@ -90,8 +90,11 @@ class Plugins:
                 retry_counter -= 1
                 if retry_counter <= 0:
                     if settings.PLUGIN_TESTING:
-                        print('Max retries, breaking loading')
+                        print('[PLUGIN] Max retries, breaking loading')
+                    # TODO error for server status
                     break
+                if settings.PLUGIN_TESTING:
+                    print(f'[PLUGIN] Above error occured during testing - {retry_counter}/{settings.PLUGIN_RETRY} retries left')
 
                 # now the loading will re-start up with init
 
@@ -190,11 +193,9 @@ class Plugins:
                     # option1: package, option2: file-based
                     if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
                         # errors are bad so disable the plugin in the database
-                        # but only if not in testing mode as that breaks in the GH pipeline
-                        if not settings.PLUGIN_TESTING:
-                            plugin_db_setting.active = False
-                            # TODO save the error to the plugin
-                            plugin_db_setting.save(no_reload=True)
+                        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
                         self.plugins_inactive[plug_key] = plugin_db_setting

From aae0018a72cf3f15dc337cd222b41438e7dc5e3e Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Mon, 22 Nov 2021 03:02:03 +0100
Subject: [PATCH 467/493] stop CI failing

---
 InvenTree/plugin/registry.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index aa180170db..d16575af2b 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -193,9 +193,10 @@ class Plugins:
                     # option1: package, option2: file-based
                     if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
                         # errors are bad so disable the plugin in the database
-                        plugin_db_setting.active = False
-                        # TODO save the error to the plugin
-                        plugin_db_setting.save(no_reload=True)
+                        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
                         self.plugins_inactive[plug_key] = plugin_db_setting

From 8236c518270dbe8eb93b958d747aa5a9aebabe6f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 23 Nov 2021 18:42:41 +0100
Subject: [PATCH 468/493] PEP fix

---
 InvenTree/plugin/helpers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 0de0c0aaec..003ca707b4 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -24,7 +24,7 @@ class IntegrationPluginError(Exception):
     def __init__(self, path, message):
         self.path = path
         self.message = message
-    
+
     def __str__(self):
         return self.message
 

From 8fddf666184708aa383d9d7c4e9a8fe55edf1369 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Tue, 23 Nov 2021 23:40:52 +0100
Subject: [PATCH 469/493] remove unneeded TODO

---
 InvenTree/plugin/plugins.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/plugin/plugins.py b/InvenTree/plugin/plugins.py
index 82256b9201..e2be1e6427 100644
--- a/InvenTree/plugin/plugins.py
+++ b/InvenTree/plugin/plugins.py
@@ -41,7 +41,6 @@ def get_modules(pkg, recursive: bool = False):
             pass
         except Exception as error:
             # this 'protects' against malformed plugin modules by more or less silently failing
-            # TODO log to logging
 
             # log to stack
             log_plugin_error({name: str(error)}, 'discovery')

From 045d0c241d7e783a70f71198562a43b37bd94f02 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Thu, 2 Dec 2021 17:33:03 +0100
Subject: [PATCH 470/493] add package-log back in

---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitignore b/.gitignore
index 4bc0bb1389..37e5ff4207 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,7 @@ dev/
 locale_stats.json
 
 # node.js
+package-lock.json
 node_modules/
 
 # maintenance locker

From 86014dd4c941096fa2b7ecd1b36f264d80b65006 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 3 Dec 2021 07:58:47 +0100
Subject: [PATCH 471/493] fix panel headings

---
 .../templates/InvenTree/settings/plugin.html  | 24 +++++++++++++------
 1 file changed, 17 insertions(+), 7 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index ef0888618b..73baced9d2 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -27,13 +27,17 @@
 </table>
 </div>
 
-<h4>{% trans "Plugin list" %}
-<div id="page-actions" class="btn-group" role="group">
-    {% url 'admin:plugin_pluginconfig_changelist' as url %}
-    {% include "admin_button.html" with url=url %}
-    <button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button>
+<div class='panel-heading'>
+    <div class='d-flex flex-wrap'>
+        <h4>{% trans "Plugin list" %}</h4>
+        {% include "spacer.html" %}
+        <div class='btn-group' role='group'>
+            {% url 'admin:plugin_pluginconfig_changelist' as url %}
+            {% include "admin_button.html" with url=url %}
+            <button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button>        
+        </div>
+    </div>
 </div>
-</h4>
 
 <div class='table-responsive'>
 <table class='table table-striped table-condensed'>
@@ -105,7 +109,13 @@
 
 {% plugin_errors as pl_errors %}
 {% if pl_errors %}
-<h4>{% trans "Plugin Error Stack" %}</h4>
+<div class='panel-heading'>
+    <div class='d-flex flex-wrap'>
+        <h4>{% trans "Plugin Error Stack" %}</h4>
+        {% include "spacer.html" %}
+    </div>
+</div>
+
 <div class='table-responsive'>
     <table class='table table-striped table-condensed'>
         <thead>

From daebae81c1634d1de6940ca38466f3346508b321 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Fri, 3 Dec 2021 08:00:04 +0100
Subject: [PATCH 472/493] fix spelling using c instead of k does not make it
 Englisch

---
 InvenTree/templates/InvenTree/settings/plugin.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 73baced9d2..e9f33bedaf 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -88,7 +88,7 @@
         {% inactive_plugin_list as in_pl_list %}
         {% if in_pl_list %}
         <tr><td colspan="5"></td></tr>
-        <tr><td colspan="5"><h6>{% trans 'Inactiv plugins' %}</h6></td></tr>
+        <tr><td colspan="5"><h6>{% trans 'Inactive plugins' %}</h6></td></tr>
         {% for plugin_key, plugin in in_pl_list.items %}
         <tr>
             <td>

From 3da5767e02b1ddf2a068c50e6d34621179984719 Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 4 Dec 2021 01:22:10 +0100
Subject: [PATCH 473/493] move version checks out into own check

---
 .github/workflows/qc_checks.yaml |  2 --
 .github/workflows/version.yml    | 21 +++++++++++++++++++++
 2 files changed, 21 insertions(+), 2 deletions(-)
 create mode 100644 .github/workflows/version.yml

diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml
index e772447025..fee237fb57 100644
--- a/.github/workflows/qc_checks.yaml
+++ b/.github/workflows/qc_checks.yaml
@@ -44,9 +44,7 @@ jobs:
 
   pep_style:
     name: PEP style (python)
-    needs: check_version
     runs-on: ubuntu-latest
-    if: always()
 
     steps:
       - name: Checkout code
diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml
new file mode 100644
index 0000000000..73d5bd8a2c
--- /dev/null
+++ b/.github/workflows/version.yml
@@ -0,0 +1,21 @@
+# Checks version number
+name: version number
+
+on:
+  pull_request:
+    branches-ignore:
+      - l10*
+
+
+jobs:
+
+  check_version:
+    name: version number
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v2
+      - name: Check version number
+        run: |
+          python3 ci/check_version_number.py --branch ${{ github.base_ref }}

From d9c6e6c4f43e712557aa4d576058b0278e33bd5f Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 4 Dec 2021 16:45:59 +0100
Subject: [PATCH 474/493] remove version nb checks

---
 .github/workflows/qc_checks.yaml | 17 -----------------
 1 file changed, 17 deletions(-)

diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml
index fee237fb57..929a299e93 100644
--- a/.github/workflows/qc_checks.yaml
+++ b/.github/workflows/qc_checks.yaml
@@ -25,23 +25,6 @@ env:
 
 
 jobs:
-
-  check_version:
-    name: version number
-    runs-on: ubuntu-latest
-    if: ${{ github.event_name == 'pull_request' }}
-
-    steps:
-      - name: Checkout Code
-        uses: actions/checkout@v2
-      - name: Check version number
-        if: ${{ github.event_name == 'pull_request' }}
-        run: |
-          python3 ci/check_version_number.py --branch ${{ github.base_ref }}
-      - name: Finish
-        if: always()
-        run: echo 'done'
-
   pep_style:
     name: PEP style (python)
     runs-on: ubuntu-latest

From ed5bb3d26471b1f416c30aeaa230a74b28dcb4ef Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 4 Dec 2021 19:49:43 +0100
Subject: [PATCH 475/493] turn around template loader order

---
 InvenTree/InvenTree/settings.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index bfe69a43f1..bd1813312b 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -359,9 +359,9 @@ TEMPLATES = [
             ],
             'loaders': [(
                 'django.template.loaders.cached.Loader', [
-                    'django.template.loaders.app_directories.Loader',
-                    'django.template.loaders.filesystem.Loader',
                     'plugin.loader.PluginTemplateLoader',
+                    'django.template.loaders.filesystem.Loader',
+                    'django.template.loaders.app_directories.Loader',
                 ])
             ],
         },

From 529987bb17a05c041cdbf3bbe2a98edda72872fc Mon Sep 17 00:00:00 2001
From: Matthias <matthias.mair@oewf.org>
Date: Sat, 4 Dec 2021 19:52:56 +0100
Subject: [PATCH 476/493] remove unneeded Todo

---
 InvenTree/plugin/urls.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index dfcfb79dd0..419dce5a88 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -15,5 +15,4 @@ def get_plugin_urls():
     for plugin in plugin_reg.plugins.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
-    # TODO wrap everything in plugin_url_wrapper
     return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))

From ae8166984fe9d4490dc0dd4da6cdc087b882e37f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sun, 5 Dec 2021 17:28:46 +1100
Subject: [PATCH 477/493] Adjustments for maintenance-mode options:

- Brings the state into the same directory as the runtime config file
---
 InvenTree/InvenTree/settings.py | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index bd1813312b..70447371f0 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -86,6 +86,9 @@ if not os.path.exists(cfg_filename):
 with open(cfg_filename, 'r') as cfg:
     CONFIG = yaml.safe_load(cfg)
 
+# We will place any config files in the same directory as the config file
+config_dir = os.path.dirname(cfg_filename)
+
 # Default action is to run the system in Debug mode
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = _is_true(get_setting(
@@ -206,6 +209,16 @@ if MEDIA_ROOT is None:
     print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
     sys.exit(1)
 
+# Options for django-maintenance-mode : https://pypi.org/project/django-maintenance-mode/
+MAINTENANCE_MODE_STATE_FILE_PATH = os.path.join(
+    config_dir,
+    'maintenance_mode_state.txt',
+)
+
+MAINTENANCE_MODE_IGNORE_ADMIN_SITE = True
+
+MAINTENANCE_MODE_IGNORE_SUPERUSER = True
+
 # List of allowed hosts (default = allow all)
 ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
 

From a821717103f8b469b71cee4f60fce50dd792cb36 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sun, 5 Dec 2021 17:56:39 +1100
Subject: [PATCH 478/493] Add a data migration which deletes any stock items
 which have been scheduled for deletion.

Also deletes any instance of the "delete_old_stock_items" worker task
---
 .../migrations/0071_auto_20211205_1733.py     | 47 +++++++++++++++++++
 1 file changed, 47 insertions(+)
 create mode 100644 InvenTree/stock/migrations/0071_auto_20211205_1733.py

diff --git a/InvenTree/stock/migrations/0071_auto_20211205_1733.py b/InvenTree/stock/migrations/0071_auto_20211205_1733.py
new file mode 100644
index 0000000000..e069f77a20
--- /dev/null
+++ b/InvenTree/stock/migrations/0071_auto_20211205_1733.py
@@ -0,0 +1,47 @@
+# Generated by Django 3.2.5 on 2021-12-05 06:33
+
+from django.db import migrations
+
+import logging
+
+
+logger = logging.getLogger('inventree')
+
+
+def delete_scheduled(apps, schema_editor):
+    """
+    Delete all stock items which are marked as 'scheduled_for_deletion'.
+
+    The issue that this field was addressing has now been fixed,
+    and so we can all move on with our lives...
+    """
+
+    StockItem = apps.get_model('stock', 'stockitem')
+
+    items = StockItem.objects.filter(scheduled_for_deletion=True)
+
+    logger.info(f"Removing {items.count()} stock items scheduled for deletion")
+
+    items.delete()
+
+    Task = apps.get_model('django_q', 'schedule')
+
+    Task.objects.filter(func='stock.tasks.delete_old_stock_items').delete()
+
+
+def reverse(apps, schema_editor):
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0070_auto_20211128_0151'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            delete_scheduled,
+            reverse_code=reverse,
+        )
+    ]

From 93a240d9c3b0d9d3b4d52b6974ce809f355f7dc0 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sun, 5 Dec 2021 18:14:14 +1100
Subject: [PATCH 479/493] Remove the "scheduled_for_deletion" field from the
 StockItem model

Reverts back to the original behaviour - stock items are just deleted
---
 InvenTree/InvenTree/apps.py                   |  7 ----
 InvenTree/InvenTree/settings.py               | 13 ++++++++
 InvenTree/build/test_build.py                 |  6 ----
 InvenTree/stock/api.py                        | 14 --------
 ...remove_stockitem_scheduled_for_deletion.py | 17 ++++++++++
 InvenTree/stock/models.py                     | 16 ++-------
 InvenTree/stock/tasks.py                      | 33 -------------------
 InvenTree/stock/test_api.py                   | 25 ++------------
 InvenTree/stock/tests.py                      | 11 -------
 9 files changed, 35 insertions(+), 107 deletions(-)
 create mode 100644 InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py

diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py
index 5f347dd1e5..12285d494b 100644
--- a/InvenTree/InvenTree/apps.py
+++ b/InvenTree/InvenTree/apps.py
@@ -69,13 +69,6 @@ class InvenTreeConfig(AppConfig):
             schedule_type=Schedule.DAILY,
         )
 
-        # Delete "old" stock items
-        InvenTree.tasks.schedule_task(
-            'stock.tasks.delete_old_stock_items',
-            schedule_type=Schedule.MINUTES,
-            minutes=30,
-        )
-
         # Delete old notification records
         InvenTree.tasks.schedule_task(
             'common.tasks.delete_old_notifications',
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index bd1813312b..70447371f0 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -86,6 +86,9 @@ if not os.path.exists(cfg_filename):
 with open(cfg_filename, 'r') as cfg:
     CONFIG = yaml.safe_load(cfg)
 
+# We will place any config files in the same directory as the config file
+config_dir = os.path.dirname(cfg_filename)
+
 # Default action is to run the system in Debug mode
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = _is_true(get_setting(
@@ -206,6 +209,16 @@ if MEDIA_ROOT is None:
     print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined")
     sys.exit(1)
 
+# Options for django-maintenance-mode : https://pypi.org/project/django-maintenance-mode/
+MAINTENANCE_MODE_STATE_FILE_PATH = os.path.join(
+    config_dir,
+    'maintenance_mode_state.txt',
+)
+
+MAINTENANCE_MODE_IGNORE_ADMIN_SITE = True
+
+MAINTENANCE_MODE_IGNORE_SUPERUSER = True
+
 # List of allowed hosts (default = allow all)
 ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
 
diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py
index f8c381f224..3ecb630c87 100644
--- a/InvenTree/build/test_build.py
+++ b/InvenTree/build/test_build.py
@@ -10,7 +10,6 @@ from InvenTree import status_codes as status
 from build.models import Build, BuildItem, get_next_build_number
 from part.models import Part, BomItem
 from stock.models import StockItem
-from stock.tasks import delete_old_stock_items
 
 
 class BuildTest(TestCase):
@@ -354,11 +353,6 @@ class BuildTest(TestCase):
         # the original BuildItem objects should have been deleted!
         self.assertEqual(BuildItem.objects.count(), 0)
 
-        self.assertEqual(StockItem.objects.count(), 8)
-
-        # Clean up old stock items
-        delete_old_stock_items()
-
         # New stock items should have been created!
         self.assertEqual(StockItem.objects.count(), 7)
 
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 760a18c72c..8a2d2fa051 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -86,17 +86,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
 
         return self.serializer_class(*args, **kwargs)
 
-    def perform_destroy(self, instance):
-        """
-        Instead of "deleting" the StockItem
-        (which may take a long time)
-        we instead schedule it for deletion at a later date.
-
-        The background worker will delete these in the future
-        """
-
-        instance.mark_for_deletion()
-
 
 class StockItemSerialize(generics.CreateAPIView):
     """
@@ -623,9 +612,6 @@ class StockList(generics.ListCreateAPIView):
 
         queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset)
 
-        # Do not expose StockItem objects which are scheduled for deletion
-        queryset = queryset.filter(scheduled_for_deletion=False)
-
         return queryset
 
     def filter_queryset(self, queryset):
diff --git a/InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py b/InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py
new file mode 100644
index 0000000000..0db2141299
--- /dev/null
+++ b/InvenTree/stock/migrations/0072_remove_stockitem_scheduled_for_deletion.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.5 on 2021-12-05 06:49
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0071_auto_20211205_1733'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='stockitem',
+            name='scheduled_for_deletion',
+        ),
+    ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 0aa63687c9..7f385e7136 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -212,18 +212,12 @@ class StockItem(MPTTModel):
         belongs_to=None,
         customer=None,
         is_building=False,
-        status__in=StockStatus.AVAILABLE_CODES,
-        scheduled_for_deletion=False,
+        status__in=StockStatus.AVAILABLE_CODES
     )
 
     # A query filter which can be used to filter StockItem objects which have expired
     EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
 
-    def mark_for_deletion(self):
-
-        self.scheduled_for_deletion = True
-        self.save()
-
     def update_serial_number(self):
         """
         Update the 'serial_int' field, to be an integer representation of the serial number.
@@ -615,12 +609,6 @@ class StockItem(MPTTModel):
                               help_text=_('Select Owner'),
                               related_name='stock_items')
 
-    scheduled_for_deletion = models.BooleanField(
-        default=False,
-        verbose_name=_('Scheduled for deletion'),
-        help_text=_('This StockItem will be deleted by the background worker'),
-    )
-
     def is_stale(self):
         """
         Returns True if this Stock item is "stale".
@@ -1327,7 +1315,7 @@ class StockItem(MPTTModel):
         self.quantity = quantity
 
         if quantity == 0 and self.delete_on_deplete and self.can_delete():
-            self.mark_for_deletion()
+            self.delete()
 
             return False
         else:
diff --git a/InvenTree/stock/tasks.py b/InvenTree/stock/tasks.py
index 9fbd875384..a2b5079b33 100644
--- a/InvenTree/stock/tasks.py
+++ b/InvenTree/stock/tasks.py
@@ -1,35 +1,2 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
-
-import logging
-
-from django.core.exceptions import AppRegistryNotReady
-
-
-logger = logging.getLogger('inventree')
-
-
-def delete_old_stock_items():
-    """
-    This function removes StockItem objects which have been marked for deletion.
-
-    Bulk "delete" operations for database entries with foreign-key relationships
-    can be pretty expensive, and thus can "block" the UI for a period of time.
-
-    Thus, instead of immediately deleting multiple StockItems, some UI actions
-    simply mark each StockItem as "scheduled for deletion".
-
-    The background worker then manually deletes these at a later stage
-    """
-
-    try:
-        from stock.models import StockItem
-    except AppRegistryNotReady:
-        logger.info("Could not delete scheduled StockItems - AppRegistryNotReady")
-        return
-
-    items = StockItem.objects.filter(scheduled_for_deletion=True)
-
-    if items.count() > 0:
-        logger.info(f"Removing {items.count()} StockItem objects scheduled for deletion")
-        items.delete()
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index 2c1b250e5f..522468a740 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -18,7 +18,6 @@ from InvenTree.api_tester import InvenTreeAPITestCase
 from common.models import InvenTreeSetting
 
 from .models import StockItem, StockLocation
-from .tasks import delete_old_stock_items
 
 
 class StockAPITestCase(InvenTreeAPITestCase):
@@ -593,11 +592,7 @@ class StockItemDeletionTest(StockAPITestCase):
 
     def test_delete(self):
 
-        # Check there are no stock items scheduled for deletion
-        self.assertEqual(
-            StockItem.objects.filter(scheduled_for_deletion=True).count(),
-            0
-        )
+        n = StockItem.objects.count()
 
         # Create and then delete a bunch of stock items
         for idx in range(10):
@@ -615,9 +610,7 @@ class StockItemDeletionTest(StockAPITestCase):
 
             pk = response.data['pk']
 
-            item = StockItem.objects.get(pk=pk)
-
-            self.assertFalse(item.scheduled_for_deletion)
+            self.assertEqual(StockItem.objects.count(), n + 1)
 
             # Request deletion via the API
             self.delete(
@@ -625,19 +618,7 @@ class StockItemDeletionTest(StockAPITestCase):
                 expected_code=204
             )
 
-        # There should be 100x StockItem objects marked for deletion
-        self.assertEqual(
-            StockItem.objects.filter(scheduled_for_deletion=True).count(),
-            10
-        )
-
-        # Perform the actual delete (will take some time)
-        delete_old_stock_items()
-
-        self.assertEqual(
-            StockItem.objects.filter(scheduled_for_deletion=True).count(),
-            0
-        )
+        self.assertEqual(StockItem.objects.count(), n)
 
 
 class StockTestResultTest(StockAPITestCase):
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index cb1bd406bb..40e6561926 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -332,8 +332,6 @@ class StockTest(TestCase):
         w1 = StockItem.objects.get(pk=100)
         w2 = StockItem.objects.get(pk=101)
 
-        self.assertFalse(w2.scheduled_for_deletion)
-
         # Take 25 units from w1 (there are only 10 in stock)
         w1.take_stock(30, None, notes='Took 30')
 
@@ -344,15 +342,6 @@ class StockTest(TestCase):
         # Take 25 units from w2 (will be deleted)
         w2.take_stock(30, None, notes='Took 30')
 
-        # w2 should now be marked for future deletion
-        w2 = StockItem.objects.get(pk=101)
-        self.assertTrue(w2.scheduled_for_deletion)
-
-        from stock.tasks import delete_old_stock_items
-
-        # Now run the "background task" to delete these stock items
-        delete_old_stock_items()
-
         # This StockItem should now have been deleted
         with self.assertRaises(StockItem.DoesNotExist):
             w2 = StockItem.objects.get(pk=101)

From 561fd6afc137721ae3f0cd905e8a6a702e0eedfa Mon Sep 17 00:00:00 2001
From: Nigel <nigel.w@nosun.ca>
Date: Sun, 5 Dec 2021 08:57:08 -0700
Subject: [PATCH 480/493] fix: Q_CLUSTER is using the database for its broker

Q_CLUSTER is using the database as its broker, and adding cache
configuration for it and not using it is pointless.  But rather than
change Q_CLUSTER's configuration to use redis as the broker, lets just
leave it using the database as the database broker has its advantages.
---
 InvenTree/InvenTree/settings.py | 10 ++--------
 1 file changed, 2 insertions(+), 8 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index df84ba315e..12c7a0e556 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -558,7 +558,7 @@ _cache_port = _cache_config.get(
 if _cache_host:
     # We are going to rely upon a possibly non-localhost for our cache,
     # so don't wait too long for the cache as nothing in the cache should be
-    # irreplacable.  Django Q Cluster will just try again later.
+    # irreplacable.
     _cache_options = {
         "CLIENT_CLASS": "django_redis.client.DefaultClient",
         "SOCKET_CONNECT_TIMEOUT": int(os.getenv("CACHE_CONNECT_TIMEOUT", "2")),
@@ -584,15 +584,9 @@ if _cache_host:
         },
     }
     CACHES = {
-        # Connection configuration for Django Q Cluster
-        "worker": {
-            "BACKEND": "django_redis.cache.RedisCache",
-            "LOCATION": f"redis://{_cache_host}:{_cache_port}/0",
-            "OPTIONS": _cache_options,
-        },
         "default": {
             "BACKEND": "django_redis.cache.RedisCache",
-            "LOCATION": f"redis://{_cache_host}:{_cache_port}/1",
+            "LOCATION": f"redis://{_cache_host}:{_cache_port}/0",
             "OPTIONS": _cache_options,
         },
     }

From a9f584cb6578d4249cfa0aa76c55ee7c2c14d33a Mon Sep 17 00:00:00 2001
From: Nigel <nigel.w@nosun.ca>
Date: Sun, 5 Dec 2021 09:18:55 -0700
Subject: [PATCH 481/493] fix: swallow Serialization failure

We can swallow the serialization exception because there is a scheduled
task that will update these later anyway.

Fixes #2241
---
 InvenTree/InvenTree/exchange.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py
index 9981e52ff7..936dd7a76e 100644
--- a/InvenTree/InvenTree/exchange.py
+++ b/InvenTree/InvenTree/exchange.py
@@ -2,6 +2,7 @@ from common.settings import currency_code_default, currency_codes
 from urllib.error import HTTPError, URLError
 
 from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
+from django.db.utils import OperationalError
 
 
 class InvenTreeExchange(SimpleExchangeBackend):
@@ -32,3 +33,12 @@ class InvenTreeExchange(SimpleExchangeBackend):
         # catch connection errors
         except (HTTPError, URLError):
             print('Encountered connection error while updating')
+        except OperationalError as e:
+            if 'SerializationFailure' in e.__cause__.__class__.__name__:
+                print('Serialization Failure while updating exchange rates')
+                # We are just going to swallow this exception because the
+                # exchange rates will be updated later by the scheduled task
+            else:
+                # Other operational errors probably are still show stoppers
+                # so reraise them so that the log contains the stacktrace
+                raise

From da020f6fd62a9c77f4fe41cdde0cbb41a6f170ef Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Mon, 6 Dec 2021 09:05:13 +1100
Subject: [PATCH 482/493] Remove extra options

---
 InvenTree/InvenTree/settings.py | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 70447371f0..19e5518eb9 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -215,10 +215,6 @@ MAINTENANCE_MODE_STATE_FILE_PATH = os.path.join(
     'maintenance_mode_state.txt',
 )
 
-MAINTENANCE_MODE_IGNORE_ADMIN_SITE = True
-
-MAINTENANCE_MODE_IGNORE_SUPERUSER = True
-
 # List of allowed hosts (default = allow all)
 ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*'])
 

From 463192e0b98498872506cec84dd1ddc2d2772c71 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Tue, 7 Dec 2021 10:33:09 +1100
Subject: [PATCH 483/493] Improved table filtering for "purchase order" table
 (as seen from "part" view)

---
 InvenTree/order/api.py                        | 29 +++++++++++++++----
 .../templates/js/translated/table_filters.js  | 12 ++++++--
 2 files changed, 34 insertions(+), 7 deletions(-)

diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index 98b9bbe934..96b886e15c 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -277,13 +277,31 @@ class POLineItemFilter(rest_filters.FilterSet):
             'part'
         ]
 
-    completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
+    pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
 
-    def filter_completed(self, queryset, name, value):
+    def filter_pending(self, queryset, name, value):
+        """
+        Filter by "pending" status (order status = pending)
         """
-        Filter by lines which are "completed" (or "not" completed)
 
-        A line is completed when received >= quantity
+        value = str2bool(value)
+
+        if value:
+            queryset = queryset.filter(order__status__in=PurchaseOrderStatus.OPEN)
+        else:
+            queryset = queryset.exclude(order__status__in=PurchaseOrderStatus.OPEN)
+
+        return queryset
+
+    order_status = rest_filters.NumberFilter(label='order_status', field_name='order__status')
+
+    received = rest_filters.BooleanFilter(label='received', method='filter_received')
+
+    def filter_received(self, queryset, name, value):
+        """
+        Filter by lines which are "received" (or "not" received)
+
+        A line is considered "received" when received >= quantity
         """
 
         value = str2bool(value)
@@ -293,7 +311,8 @@ class POLineItemFilter(rest_filters.FilterSet):
         if value:
             queryset = queryset.filter(q)
         else:
-            queryset = queryset.exclude(q)
+            # Only count "pending" orders
+            queryset = queryset.exclude(q).filter(order__status__in=PurchaseOrderStatus.OPEN)
 
         return queryset
 
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index b7ba79e498..76c129abdc 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -308,9 +308,17 @@ function getAvailableTableFilters(tableKey) {
     // Filters for PurchaseOrderLineItem table
     if (tableKey == 'purchaseorderlineitem') {
         return {
-            completed: {
+            pending: {
                 type: 'bool',
-                title: '{% trans "Completed" %}',
+                title: '{% trans "Pending" %}',
+            },
+            received: {
+                type: 'bool',
+                title: '{% trans "Received" %}',
+            },
+            order_status: {
+                title: '{% trans "Order status" %}',
+                options: purchaseOrderCodes,
             },
         };
     }

From 1a85e4f21ddc556627b6a930ba6b189307fcab9b Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Tue, 7 Dec 2021 10:51:40 +1100
Subject: [PATCH 484/493] Translateable string fixeas

---
 InvenTree/barcodes/api.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/InvenTree/barcodes/api.py b/InvenTree/barcodes/api.py
index 38041096ab..4b853ab438 100644
--- a/InvenTree/barcodes/api.py
+++ b/InvenTree/barcodes/api.py
@@ -188,21 +188,21 @@ class BarcodeAssign(APIView):
 
             if plugin.getStockItem() is not None:
                 match_found = True
-                response['error'] = _('Barcode already matches StockItem object')
+                response['error'] = _('Barcode already matches Stock Item')
 
             if plugin.getStockLocation() is not None:
                 match_found = True
-                response['error'] = _('Barcode already matches StockLocation object')
+                response['error'] = _('Barcode already matches Stock Location')
 
             if plugin.getPart() is not None:
                 match_found = True
-                response['error'] = _('Barcode already matches Part object')
+                response['error'] = _('Barcode already matches Part')
 
             if not match_found:
                 item = plugin.getStockItemByHash()
 
                 if item is not None:
-                    response['error'] = _('Barcode hash already matches StockItem object')
+                    response['error'] = _('Barcode hash already matches Stock Item')
                     match_found = True
 
         else:
@@ -214,13 +214,13 @@ class BarcodeAssign(APIView):
             # Lookup stock item by hash
             try:
                 item = StockItem.objects.get(uid=hash)
-                response['error'] = _('Barcode hash already matches StockItem object')
+                response['error'] = _('Barcode hash already matches Stock Item')
                 match_found = True
             except StockItem.DoesNotExist:
                 pass
 
         if not match_found:
-            response['success'] = _('Barcode associated with StockItem')
+            response['success'] = _('Barcode associated with Stock Item')
 
             # Save the barcode hash
             item.uid = response['hash']

From c143826e9bc4b021ebddf24e843d6fe540a751e8 Mon Sep 17 00:00:00 2001
From: Weng Tad <wengtad93@gmail.com>
Date: Tue, 7 Dec 2021 13:59:51 +0800
Subject: [PATCH 485/493] fix: stock tracking table aligment

Fixes #2428
---
 InvenTree/InvenTree/static/css/inventree.css | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css
index a61696f547..98aead45ee 100644
--- a/InvenTree/InvenTree/static/css/inventree.css
+++ b/InvenTree/InvenTree/static/css/inventree.css
@@ -438,6 +438,12 @@
     width: 30%;
 }
 
+/* tracking table column size */
+#track-table .table-condensed th {
+    inline-size: 30%;
+    overflow-wrap: break-word;
+}
+
 .panel-heading .badge {
     float: right;
 }

From c8c35e2f042c9ad8ce7ef545469792b6f40f55fc Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 8 Dec 2021 09:55:41 +1100
Subject: [PATCH 486/493] Remove old task to delete expired sessions

- Does not apply any more with new session management
---
 InvenTree/InvenTree/apps.py                   |  6 ----
 InvenTree/InvenTree/tasks.py                  | 19 ----------
 .../migrations/0013_auto_20211207_2250.py     | 36 +++++++++++++++++++
 3 files changed, 36 insertions(+), 25 deletions(-)
 create mode 100644 InvenTree/common/migrations/0013_auto_20211207_2250.py

diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py
index 12285d494b..b4563c7c65 100644
--- a/InvenTree/InvenTree/apps.py
+++ b/InvenTree/InvenTree/apps.py
@@ -57,12 +57,6 @@ class InvenTreeConfig(AppConfig):
             schedule_type=Schedule.DAILY,
         )
 
-        # Remove expired sessions
-        InvenTree.tasks.schedule_task(
-            'InvenTree.tasks.delete_expired_sessions',
-            schedule_type=Schedule.DAILY,
-        )
-
         # Delete old error messages
         InvenTree.tasks.schedule_task(
             'InvenTree.tasks.delete_old_error_logs',
diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py
index adb9c0a370..18c3bcc564 100644
--- a/InvenTree/InvenTree/tasks.py
+++ b/InvenTree/InvenTree/tasks.py
@@ -231,25 +231,6 @@ def check_for_updates():
     )
 
 
-def delete_expired_sessions():
-    """
-    Remove any expired user sessions from the database
-    """
-
-    try:
-        from django.contrib.sessions.models import Session
-
-        # Delete any sessions that expired more than a day ago
-        expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1))
-
-        if expired.count() > 0:
-            logger.info(f"Deleting {expired.count()} expired sessions.")
-            expired.delete()
-
-    except AppRegistryNotReady:
-        logger.info("Could not perform 'delete_expired_sessions' - App registry not ready")
-
-
 def update_exchange_rates():
     """
     Update currency exchange rates
diff --git a/InvenTree/common/migrations/0013_auto_20211207_2250.py b/InvenTree/common/migrations/0013_auto_20211207_2250.py
new file mode 100644
index 0000000000..2cb061f028
--- /dev/null
+++ b/InvenTree/common/migrations/0013_auto_20211207_2250.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.2.5 on 2021-12-07 22:50
+
+from django.db import migrations
+
+
+def delete_task(apps, schema_editor):
+    """
+    Remove scheduled task to delete old user sessions.
+
+    Ref: https://github.com/inventree/InvenTree/issues/2429
+    """
+
+    Task = apps.get_model('django_q', 'schedule')
+
+    Task.objects.filter(func='InvenTree.tasks.delete_expired_sessions').delete()
+
+
+def ksat_eteled(apps, schema_editor):
+    """
+    Dummy function provided for reverse migrations
+    """
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0012_notificationentry'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            delete_task,
+            reverse_code=ksat_eteled,
+        )
+    ]

From b19a7cc4fb89b61e164daadbfaf00205247b671e Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 8 Dec 2021 22:26:59 +1100
Subject: [PATCH 487/493] Run at app startup, not as a migration

---
 InvenTree/InvenTree/apps.py                   | 21 +++++++++++
 .../migrations/0013_auto_20211207_2250.py     | 36 -------------------
 2 files changed, 21 insertions(+), 36 deletions(-)
 delete mode 100644 InvenTree/common/migrations/0013_auto_20211207_2250.py

diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py
index b4563c7c65..faef1a6cdb 100644
--- a/InvenTree/InvenTree/apps.py
+++ b/InvenTree/InvenTree/apps.py
@@ -18,11 +18,32 @@ class InvenTreeConfig(AppConfig):
     def ready(self):
 
         if canAppAccessDatabase():
+
+            self.remove_obsolete_tasks()
+
             self.start_background_tasks()
 
             if not isInTestMode():
                 self.update_exchange_rates()
 
+    def remove_obsolete_tasks(self):
+        """
+        Delete any obsolete scheduled tasks in the database
+        """
+
+        obsolete = [
+            'InvenTree.tasks.delete_expired_sessions',
+            'stock.tasks.delete_old_stock_items',
+        ]
+
+        try:
+            from django_q.models import Schedule
+        except (AppRegistryNotReady):
+            return
+
+        # Remove any existing obsolete tasks
+        Schedule.objects.filter(func__in=obsolete).delete()
+
     def start_background_tasks(self):
 
         try:
diff --git a/InvenTree/common/migrations/0013_auto_20211207_2250.py b/InvenTree/common/migrations/0013_auto_20211207_2250.py
deleted file mode 100644
index 2cb061f028..0000000000
--- a/InvenTree/common/migrations/0013_auto_20211207_2250.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Generated by Django 3.2.5 on 2021-12-07 22:50
-
-from django.db import migrations
-
-
-def delete_task(apps, schema_editor):
-    """
-    Remove scheduled task to delete old user sessions.
-
-    Ref: https://github.com/inventree/InvenTree/issues/2429
-    """
-
-    Task = apps.get_model('django_q', 'schedule')
-
-    Task.objects.filter(func='InvenTree.tasks.delete_expired_sessions').delete()
-
-
-def ksat_eteled(apps, schema_editor):
-    """
-    Dummy function provided for reverse migrations
-    """
-    pass
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('common', '0012_notificationentry'),
-    ]
-
-    operations = [
-        migrations.RunPython(
-            delete_task,
-            reverse_code=ksat_eteled,
-        )
-    ]

From e0d52843a45de3b2e891845b405f391d619218d5 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 8 Dec 2021 23:01:26 +1100
Subject: [PATCH 488/493] Basic stock assignment serializer implementation

---
 InvenTree/company/fixtures/company.yaml |   1 +
 InvenTree/stock/api.py                  |  18 ++++
 InvenTree/stock/serializers.py          | 118 ++++++++++++++++++++++++
 3 files changed, 137 insertions(+)

diff --git a/InvenTree/company/fixtures/company.yaml b/InvenTree/company/fixtures/company.yaml
index 3113d6209f..02a0a4e830 100644
--- a/InvenTree/company/fixtures/company.yaml
+++ b/InvenTree/company/fixtures/company.yaml
@@ -17,6 +17,7 @@
   fields:
     name: Zerg Corp
     description: We eat the competition
+    is_customer: False
 
 - model: company.company
   pk: 4
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 8a2d2fa051..26787878a8 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -163,6 +163,23 @@ class StockTransfer(StockAdjustView):
     serializer_class = StockSerializers.StockTransferSerializer
 
 
+class StockAssign(generics.CreateAPIView):
+    """
+    API endpoint for assigning stock to a particular customer
+    """
+
+    queryset = StockItem.objects.all()
+    serializer_class = StockSerializers.StockAssignmentSerializer
+
+    def get_serializer_context(self):
+
+        ctx = super().get_serializer_context()
+
+        ctx['request'] = self.request
+
+        return ctx
+
+
 class StockLocationList(generics.ListCreateAPIView):
     """
     API endpoint for list view of StockLocation objects:
@@ -1174,6 +1191,7 @@ stock_api_urls = [
     url(r'^add/', StockAdd.as_view(), name='api-stock-add'),
     url(r'^remove/', StockRemove.as_view(), name='api-stock-remove'),
     url(r'^transfer/', StockTransfer.as_view(), name='api-stock-transfer'),
+    url(r'^assign/', StockAssign.as_view(), name='api-stock-assign'),
 
     # StockItemAttachment API endpoints
     url(r'^attachment/', include([
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 9bd5ea64be..a7228a81d9 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -28,6 +28,8 @@ from .models import StockItemTestResult
 
 import common.models
 from common.settings import currency_code_default, currency_code_mappings
+
+import company.models
 from company.serializers import SupplierPartSerializer
 
 import InvenTree.helpers
@@ -537,6 +539,122 @@ class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
         ]
 
 
+class StockAssignmentItemSerializer(serializers.Serializer):
+    """
+    Serializer for a single StockItem with in StockAssignment request.
+
+    Here, the particular StockItem is being assigned (manually) to a customer
+
+    Fields:
+        - item: StockItem object
+    """
+
+    class Meta:
+        fields = [
+            'item',
+        ]
+
+    item = serializers.PrimaryKeyRelatedField(
+        queryset=StockItem.objects.all(),
+        many=False,
+        allow_null=False,
+        required=True,
+        label=_('Stock Item'),
+    )
+
+    def validate_item(self, item):
+
+        # The item must currently be "in stock"
+        if not item.in_stock:
+            raise ValidationError(_("Item must be in stock"))
+
+        # The item must not be allocated to a sales order
+        if item.sales_order_allocations.count() > 0:
+            raise ValidationError(_("Item is allocated to a sales order"))
+
+        # The item must not be allocated to a build order
+        if item.allocations.count() > 0:
+            raise ValidationError(_("Item is allocated to a build order"))
+
+        return item
+
+
+class StockAssignmentSerializer(serializers.Serializer):
+    """
+    Serializer for assigning one (or more) stock items to a customer.
+
+    This is a manual assignment process, separate for (for example) a Sales Order
+    """
+
+    class Meta:
+        fields = [
+            'items',
+            'customer',
+            'notes',
+        ]
+
+    items = StockAssignmentItemSerializer(
+        many=True,
+        required=True,
+    )
+
+    customer = serializers.PrimaryKeyRelatedField(
+        queryset=company.models.Company.objects.all(),
+        many=False,
+        allow_null=False,
+        required=True,
+        label=_('Customer'),
+        help_text=_('Customer to assign stock items'),
+    )
+
+    def validate_customer(self, customer):
+
+        if customer and not customer.is_customer:
+            raise ValidationError(_('Selected company is not a customer'))
+
+        return customer
+
+    notes = serializers.CharField(
+        required=False,
+        allow_blank=True,
+        label=_('Notes'),
+        help_text=_('Stock assignment notes'),
+    )
+
+    def validate(self, data):
+
+        data = super().validate(data)
+
+        items = data.get('items', [])
+
+        if len(items) == 0:
+            raise ValidationError(_("A list of stock items must be provided"))
+
+        return data
+
+    def save(self):
+
+        request = self.context['request']
+
+        user = getattr(request, 'user', None)
+
+        data = self.validated_data
+
+        items = data['items']
+        customer = data['customer']
+        notes = data.get('notes', '')
+
+        with transaction.atomic():
+            for item in items:
+
+                stock_item = item['item']
+
+                stock_item.allocateToCustomer(
+                    customer,
+                    user=user,
+                    notes=notes,
+                )
+
 class StockAdjustmentItemSerializer(serializers.Serializer):
     """
     Serializer for a single StockItem within a stock adjument request.

From c36687af22e936e00d93d3117dc198a79447e85f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Wed, 8 Dec 2021 23:45:47 +1100
Subject: [PATCH 489/493] Add unit test for new API serializer

---
 .../build/migrations/0018_build_reference.py  |   3 +-
 InvenTree/stock/test_api.py                   | 112 +++++++++++++++++-
 2 files changed, 112 insertions(+), 3 deletions(-)

diff --git a/InvenTree/build/migrations/0018_build_reference.py b/InvenTree/build/migrations/0018_build_reference.py
index be4f7da36f..75abbfbc06 100644
--- a/InvenTree/build/migrations/0018_build_reference.py
+++ b/InvenTree/build/migrations/0018_build_reference.py
@@ -19,7 +19,8 @@ def add_default_reference(apps, schema_editor):
         build.save()
         count += 1
 
-    print(f"\nUpdated build reference for {count} existing BuildOrder objects")
+    if count > 0:
+        print(f"\nUpdated build reference for {count} existing BuildOrder objects")
 
 
 def reverse_default_reference(apps, schema_editor):
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index 522468a740..56e6282297 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -16,8 +16,9 @@ from InvenTree.status_codes import StockStatus
 from InvenTree.api_tester import InvenTreeAPITestCase
 
 from common.models import InvenTreeSetting
-
-from .models import StockItem, StockLocation
+import company.models
+import part.models
+from stock.models import StockItem, StockLocation
 
 
 class StockAPITestCase(InvenTreeAPITestCase):
@@ -732,3 +733,110 @@ class StockTestResultTest(StockAPITestCase):
 
             # Check that an attachment has been uploaded
             self.assertIsNotNone(response.data['attachment'])
+
+
+class StockAssignTest(StockAPITestCase):
+    """
+    Unit tests for the stock assignment API endpoint,
+    where stock items are manually assigned to a customer
+    """
+
+    URL = reverse('api-stock-assign')
+
+    def test_invalid(self):
+
+        # Test with empty data
+        response = self.post(
+            self.URL,
+            data={},
+            expected_code=400,
+        )
+
+        self.assertIn('This field is required', str(response.data['items']))
+        self.assertIn('This field is required', str(response.data['customer']))
+
+        # Test with an invalid customer
+        response = self.post(
+            self.URL,
+            data={
+                'customer': 999,
+            },
+            expected_code=400,
+        )
+
+        self.assertIn('object does not exist', str(response.data['customer']))
+
+        # Test with a company which is *not* a customer
+        response = self.post(
+            self.URL,
+            data={
+                'customer': 3,
+            },
+            expected_code=400,
+        )
+
+        self.assertIn('company is not a customer', str(response.data['customer']))
+
+        # Test with an empty items list
+        response = self.post(
+            self.URL,
+            data={
+                'items': [],
+                'customer': 4,
+            },
+            expected_code=400,
+        )
+
+        self.assertIn('A list of stock items must be provided', str(response.data))
+
+        stock_item = StockItem.objects.create(
+            part=part.models.Part.objects.get(pk=1),
+            status=StockStatus.DESTROYED,
+            quantity=5,
+        )
+
+        response = self.post(
+            self.URL,
+            data={
+                'items': [
+                    {
+                        'item': stock_item.pk,
+                    },
+                ],
+                'customer': 4,
+            },
+            expected_code=400,
+        )
+
+        self.assertIn('Item must be in stock', str(response.data['items'][0]))
+
+    def test_valid(self):
+
+        stock_items = []
+
+        for i in range(5):
+
+            stock_item = StockItem.objects.create(
+                part=part.models.Part.objects.get(pk=1),
+                quantity=i+5,
+            )
+
+            stock_items.append({
+                'item': stock_item.pk
+            })
+
+        customer = company.models.Company.objects.get(pk=4)
+
+        self.assertEqual(customer.assigned_stock.count(), 0)
+
+        response = self.post(
+            self.URL,
+            data={
+                'items': stock_items,
+                'customer': 4,
+            },
+            expected_code=201,
+        )
+
+        # 5 stock items should now have been assigned to this customer
+        self.assertEqual(customer.assigned_stock.count(), 5)

From 96a885e4e15a371591aaa2877f23ed7963556a6d Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 9 Dec 2021 00:20:45 +1100
Subject: [PATCH 490/493] client side form for assigning stock items to
 customers

---
 InvenTree/stock/forms.py                      |  14 --
 .../stock/templates/stock/item_base.html      |  16 +-
 InvenTree/stock/urls.py                       |   1 -
 InvenTree/stock/views.py                      |  33 ----
 InvenTree/templates/js/translated/stock.js    | 158 +++++++++++++++++-
 5 files changed, 168 insertions(+), 54 deletions(-)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 8e998460ca..dcbf722997 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -21,20 +21,6 @@ from part.models import Part
 from .models import StockLocation, StockItem, StockItemTracking
 
 
-class AssignStockItemToCustomerForm(HelperForm):
-    """
-    Form for manually assigning a StockItem to a Customer
-
-    TODO: This could be a simple API driven form!
-    """
-
-    class Meta:
-        model = StockItem
-        fields = [
-            'customer',
-        ]
-
-
 class ReturnStockItemForm(HelperForm):
     """
     Form for manually returning a StockItem into stock
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index 949c172e9e..64b45ed0c8 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -568,11 +568,19 @@ $("#stock-convert").click(function() {
 
 {% if item.in_stock %}
 $("#stock-assign-to-customer").click(function() {
-    launchModalForm("{% url 'stock-item-assign' item.id %}",
-        {
-            reload: true,
+
+    inventreeGet('{% url "api-stock-detail" item.pk %}', {}, {
+        success: function(response) {
+            assignStockToCustomer(
+                [response],
+                {
+                    success: function() {
+                        location.reload();
+                    },
+                }
+            );
         }
-    );
+    });
 });
 
 $("#stock-move").click(function() {
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index eb4aa2e65c..7f35904b51 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -23,7 +23,6 @@ stock_item_detail_urls = [
     url(r'^delete/', views.StockItemDelete.as_view(), name='stock-item-delete'),
     url(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
     url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
-    url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
     url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
     url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
 
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 6d93ae47e0..27801f0ed6 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -294,39 +294,6 @@ class StockLocationQRCode(QRCodeView):
             return None
 
 
-class StockItemAssignToCustomer(AjaxUpdateView):
-    """
-    View for manually assigning a StockItem to a Customer
-    """
-
-    model = StockItem
-    ajax_form_title = _("Assign to Customer")
-    context_object_name = "item"
-    form_class = StockForms.AssignStockItemToCustomerForm
-
-    def validate(self, item, form, **kwargs):
-
-        customer = form.cleaned_data.get('customer', None)
-
-        if not customer:
-            form.add_error('customer', _('Customer must be specified'))
-
-    def save(self, item, form, **kwargs):
-        """
-        Assign the stock item to the customer.
-        """
-
-        customer = form.cleaned_data.get('customer', None)
-
-        if customer:
-            item = item.allocateToCustomer(
-                customer,
-                user=self.request.user
-            )
-
-            item.clearAllocations()
-
-
 class StockItemReturnToStock(AjaxUpdateView):
     """
     View for returning a stock item (which is assigned to a customer) to stock.
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index d6de4fdd45..e9e97e3b87 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -38,6 +38,7 @@
 */
 
 /* exported
+    assignStockToCustomer,
     createNewStockItem,
     createStockLocation,
     duplicateStockItem,
@@ -533,13 +534,166 @@ function exportStock(params={}) {
                 url += `&${key}=${params[key]}`;
             }
 
-            console.log(url);
             location.href = url;
         }
     });
 }
 
 
+/**
+ * Assign multiple stock items to a customer
+ */
+function assignStockToCustomer(items, options={}) {
+
+    // Generate HTML content for the form
+    var html = `
+    <table class='table table-striped table-condensed' id='stock-assign-table'>
+    <thead>
+        <tr>
+            <th>{% trans "Part" %}</th>
+            <th>{% trans "Stock Item" %}</th>
+            <th>{% trans "Location" %}</th>
+            <th></th>
+        </tr>
+    </thead>
+    <tbody>
+    `;
+
+    for (var idx = 0; idx < items.length; idx++) {
+
+        var item = items[idx];
+        
+        var pk = item.pk;
+
+        var part = item.part_detail;
+
+        var thumbnail = thumbnailImage(part.thumbnail || part.image);
+
+        var status = stockStatusDisplay(item.status, {classes: 'float-right'});
+
+        var quantity = '';
+
+        if (item.serial && item.quantity == 1) {
+            quantity = `{% trans "Serial" %}: ${item.serial}`;
+        } else {
+            quantity = `{% trans "Quantity" %}: ${item.quantity}`;
+        }
+
+        quantity += status;
+
+        var location = locationDetail(item, false);
+
+        var buttons = `<div class='btn-group' role='group'>`;
+
+        buttons += makeIconButton(
+            'fa-times icon-red',
+            'button-stock-item-remove',
+            pk,
+            '{% trans "Remove row" %}',
+        );
+
+        buttons += '</div>';
+
+        html += `
+            <tr id='stock_item_${pk}' class='stock-item'row'>
+                <td id='part_${pk}'>${thumbnail} ${part.full_name}</td>
+                <td id='stock_${pk}'>
+                    <div id='div_id_items_item_${pk}'>
+                        ${quantity}
+                        <div id='errors-items_item_${pk}'></div>
+                    </div>
+                </td>
+                <td id='location_${pk}'>${location}</td>
+                <td id='buttons_${pk}'>${buttons}</td>
+            </tr>
+        `;
+    }
+
+    html += `</tbody></table>`;
+
+    constructForm('{% url "api-stock-assign" %}', {
+        method: 'POST',
+        preFormContent: html,
+        fields: {
+            'customer': {
+                value: options.customer,
+                filters: {
+                    is_customer: true,
+                },
+            },
+            'notes': {},
+        },
+        confirm: true,
+        confirmMessage: '{% trans "Confirm stock assignment" %}',
+        title: '{% trans "Assign Stock to Customer" %}',
+        afterRender: function(fields, opts) {
+            // Add button callbacks to remove rows
+            $(opts.modal).find('.button-stock-item-remove').click(function() {
+                var pk = $(this).attr('pk');
+
+                $(opts.modal).find(`#stock_item_${pk}`).remove();
+            });
+        },
+        onSubmit: function(fields, opts) {
+
+            // Extract data elements from the form
+            var data = {
+                customer: getFormFieldValue('customer', {}, opts),
+                notes: getFormFieldValue('notes', {}, opts),
+                items: [],
+            };
+
+            var item_pk_values = [];
+
+            items.forEach(function(item) {
+                var pk = item.pk;
+
+                // Does the row exist in the form?
+                var row = $(opts.modal).find(`#stock_item_${pk}`);
+
+                if (row) {
+                    item_pk_values.push(pk);
+
+                    data.items.push({
+                        item: pk,
+                    });
+                }
+            });
+
+            opts.nested = {
+                'items': item_pk_values,
+            }
+
+            inventreePut(
+                '{% url "api-stock-assign" %}',
+                data,
+                {
+                    method: 'POST',
+                    success: function(response) {
+                        $(opts.modal).modal('hide');
+
+                        if (options.success) {
+                            options.success(response);
+                        }
+                    },
+                    error: function(xhr) {
+                        switch (xhr.status) {
+                        case 400:
+                            handleFormErrors(xhr.responseJSON, fields, opts);
+                            break;
+                        default:
+                            $(opts.modal).modal('hide');
+                            showApiError(xhr, opts.url);
+                            break;
+                        }
+                    }
+                }
+            );
+        }
+    });
+}
+
+
 /**
  * Perform stock adjustments
  */
@@ -1098,7 +1252,7 @@ function locationDetail(row, showLink=true) {
         // StockItem has been assigned to a sales order
         text = '{% trans "Assigned to Sales Order" %}';
         url = `/order/sales-order/${row.sales_order}/`;
-    } else if (row.location) {
+    } else if (row.location && row.location_detail) {
         text = row.location_detail.pathstring;
         url = `/stock/location/${row.location}/`;
     } else {

From 4a453b0a35c115fc7b9dac5d6d8e99727b502ccb Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 9 Dec 2021 00:32:50 +1100
Subject: [PATCH 491/493] Assign multiple stock items to a customer at one

---
 InvenTree/stock/serializers.py             |  5 +++++
 InvenTree/stock/test_api.py                |  4 ++--
 InvenTree/templates/js/translated/stock.js | 15 +++++++++++++--
 InvenTree/templates/stock_table.html       |  1 +
 4 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index a7228a81d9..fb78eaeaa0 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -568,6 +568,10 @@ class StockAssignmentItemSerializer(serializers.Serializer):
         if not item.in_stock:
             raise ValidationError(_("Item must be in stock"))
 
+        # The base part must be "salable"
+        if not item.part.salable:
+            raise ValidationError(_("Part must be salable"))
+
         # The item must not be allocated to a sales order
         if item.sales_order_allocations.count() > 0:
             raise ValidationError(_("Item is allocated to a sales order"))
@@ -655,6 +659,7 @@ class StockAssignmentSerializer(serializers.Serializer):
                     notes=notes,
                 )
 
+
 class StockAdjustmentItemSerializer(serializers.Serializer):
     """
     Serializer for a single StockItem within a stock adjument request.
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index 56e6282297..ec5064f08b 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -818,7 +818,7 @@ class StockAssignTest(StockAPITestCase):
 
             stock_item = StockItem.objects.create(
                 part=part.models.Part.objects.get(pk=1),
-                quantity=i+5,
+                quantity=i + 5,
             )
 
             stock_items.append({
@@ -829,7 +829,7 @@ class StockAssignTest(StockAPITestCase):
 
         self.assertEqual(customer.assigned_stock.count(), 0)
 
-        response = self.post(
+        self.post(
             self.URL,
             data={
                 'items': stock_items,
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index e9e97e3b87..8babcece69 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -651,7 +651,7 @@ function assignStockToCustomer(items, options={}) {
                 // Does the row exist in the form?
                 var row = $(opts.modal).find(`#stock_item_${pk}`);
 
-                if (row) {
+                if (row.exists()) {
                     item_pk_values.push(pk);
 
                     data.items.push({
@@ -931,7 +931,7 @@ function adjustStock(action, items, options={}) {
                 // Does the row exist in the form?
                 var row = $(opts.modal).find(`#stock_item_${pk}`);
 
-                if (row) {
+                if (row.exists()) {
 
                     item_pk_values.push(pk);
                     
@@ -1875,6 +1875,17 @@ function loadStockTable(table, options) {
         stockAdjustment('move');
     });
 
+    $('#multi-item-assign').click(function() {
+
+        var items = $(table).bootstrapTable('getSelections');
+
+        assignStockToCustomer(items, {
+            success: function() {
+                $(table).bootstrapTable('refresh');
+            }
+        });
+    });
+
     $('#multi-item-order').click(function() {
         var selections = $(table).bootstrapTable('getSelections');
 
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index 71c6734818..1f873d7c58 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -50,6 +50,7 @@
                     <li><a class='dropdown-item' href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'><span class='fas fa-check-circle'></span> {% trans "Count stock" %}</a></li>
                     <li><a class='dropdown-item' href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'><span class='fas fa-exchange-alt'></span> {% trans "Move stock" %}</a></li>
                     <li><a class='dropdown-item' href='#' id='multi-item-order' title='{% trans "Order selected items" %}'><span class='fas fa-shopping-cart'></span> {% trans "Order stock" %}</a></li>
+                    <li><a class='dropdown-item' href='#' id='multi-item-assign' title='{% trans "Assign to customer" %}'><span class='fas fa-user-tie'></span> {% trans "Assign to customer" %}</a></li>
                     <li><a class='dropdown-item' href='#' id='multi-item-set-status' title='{% trans "Change status" %}'><span class='fas fa-exclamation-circle'></span> {% trans "Change stock status" %}</a></li>
                     {% endif %}
                     {% if roles.stock.delete %}

From c1163b9f6b7582e326244b74146b61218cd9052f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 9 Dec 2021 09:07:36 +1100
Subject: [PATCH 492/493] Adds missing semicolon

---
 InvenTree/templates/js/translated/stock.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index 8babcece69..2541f77272 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -662,7 +662,7 @@ function assignStockToCustomer(items, options={}) {
 
             opts.nested = {
                 'items': item_pk_values,
-            }
+            };
 
             inventreePut(
                 '{% url "api-stock-assign" %}',

From fefe39b88d28c9af501a5b139fbf9e1dc31cbd53 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 9 Dec 2021 10:04:33 +1100
Subject: [PATCH 493/493] Fixes for unit tests

---
 InvenTree/stock/test_api.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index ec5064f08b..fe76e6c1c0 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -817,7 +817,7 @@ class StockAssignTest(StockAPITestCase):
         for i in range(5):
 
             stock_item = StockItem.objects.create(
-                part=part.models.Part.objects.get(pk=1),
+                part=part.models.Part.objects.get(pk=25),
                 quantity=i + 5,
             )
 
@@ -829,7 +829,7 @@ class StockAssignTest(StockAPITestCase):
 
         self.assertEqual(customer.assigned_stock.count(), 0)
 
-        self.post(
+        response = self.post(
             self.URL,
             data={
                 'items': stock_items,
@@ -838,5 +838,7 @@ class StockAssignTest(StockAPITestCase):
             expected_code=201,
         )
 
+        self.assertEqual(response.data['customer'], 4)
+
         # 5 stock items should now have been assigned to this customer
         self.assertEqual(customer.assigned_stock.count(), 5)