From 260c51f6fceed99256a9893c6364e59d195d5c9c Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Nov 2021 22:35:28 +0100 Subject: [PATCH 01/12] Pulls out the webhook dev for partial merge Fixes #2036 --- InvenTree/InvenTree/helpers.py | 15 ++ InvenTree/InvenTree/urls.py | 3 + InvenTree/common/admin.py | 7 + InvenTree/common/api.py | 91 ++++++++- .../0013_webhookendpoint_webhookmessage.py | 40 ++++ InvenTree/common/models.py | 186 ++++++++++++++++++ InvenTree/common/tests.py | 121 +++++++++++- InvenTree/users/models.py | 2 + 8 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 InvenTree/common/migrations/0013_webhookendpoint_webhookmessage.py diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index fd86306627..6ae31d6f5f 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -696,3 +696,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 diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 648600265c..bd179689e8 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -67,6 +67,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 c5950a0c0a..3ec0e32da1 100644 --- a/InvenTree/common/admin.py +++ b/InvenTree/common/admin.py @@ -38,6 +38,11 @@ class UserSettingsAdmin(ImportExportModelAdmin): return [] +class WebhookAdmin(ImportExportModelAdmin): + + list_display = ('endpoint_id', 'name', 'active', 'user') + + class NotificationEntryAdmin(admin.ModelAdmin): list_display = ('key', 'uid', 'updated', ) @@ -45,4 +50,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) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 6dd51bdff1..f71bf5b958 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -5,13 +5,101 @@ 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): @@ -131,6 +219,7 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView): common_api_urls = [ + path('webhook//', WebhookView.as_view(), name='api-webhook'), # User settings url(r'^user/', include([ @@ -148,6 +237,6 @@ common_api_urls = [ # Global Settings List url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), - ])) + ])), ] diff --git a/InvenTree/common/migrations/0013_webhookendpoint_webhookmessage.py b/InvenTree/common/migrations/0013_webhookendpoint_webhookmessage.py new file mode 100644 index 0000000000..d926c8ef3f --- /dev/null +++ b/InvenTree/common/migrations/0013_webhookendpoint_webhookmessage.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.5 on 2021-11-19 21:34 + +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', '0012_notificationentry'), + ] + + 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')), + ('secret', models.CharField(blank=True, help_text='Shared secret for HMAC', max_length=255, null=True, verbose_name='Secret')), + ('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')), + ], + ), + 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 51b4bfd00d..5ea5516a79 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -9,6 +9,12 @@ 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 @@ -20,6 +26,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 @@ -1346,6 +1354,184 @@ 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. diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index c20dc5d126..0ec1812774 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals - +from http import HTTPStatus +import json from datetime import timedelta -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 NotificationEntry +from .models import InvenTreeSetting, WebhookEndpoint, WebhookMessage, NotificationEntry +from .api import WebhookView class SettingsTest(TestCase): @@ -90,6 +91,118 @@ 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): diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 4d1b46ae5d..4599408163 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -151,6 +151,8 @@ class RuleSet(models.Model): 'common_colortheme', 'common_inventreesetting', 'common_inventreeusersetting', + 'common_webhookendpoint', + 'common_webhookmessage', 'common_notificationentry', 'company_contact', 'users_owner', From a92ea1e5c702cb0e63832b0d43946af2e4fa7089 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 12:32:41 +1100 Subject: [PATCH 02/12] Add PLUGINS_ENABLED variable in settings.py --- InvenTree/InvenTree/settings.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 89e60a597e..2f80844bf1 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -52,8 +52,6 @@ with open(cfg_filename, 'r') as cfg: # We will place any config files in the same directory as the config file config_dir = os.path.dirname(cfg_filename) -PLUGIN_FILE = get_plugin_file() - # 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( @@ -873,6 +871,14 @@ MARKDOWNIFY_BLEACH = False # Maintenance mode MAINTENANCE_MODE_RETRY_AFTER = 60 +# Are plugins enabled? +PLUGINS_ENABLED = _is_true(get_setting( + 'INVENTREE_PLUGINS_ENABLED', + CONFIG.get('plugins_enabled', False), +)) + +PLUGIN_FILE = get_plugin_file() + # Plugin Directories (local plugins will be loaded from these directories) PLUGIN_DIRS = ['plugin.builtin', ] From ad851a653ce611c3872ab4b2403bc24f4b39fdc9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 12:34:34 +1100 Subject: [PATCH 03/12] Add default value for plugins_enabled to configuration template file --- InvenTree/config_template.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index d8f780bb36..b14c1224ce 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -102,6 +102,10 @@ debug: True # and only if InvenTree is accessed from a local IP (127.0.0.1) debug_toolbar: False +# Set this variable to True to enable InvenTree Plugins +# Alternatively, use the environment variable INVENTREE_PLUGINS_ENABLED +plugins_enabled: False + # Configure the system logging level # Use environment variable INVENTREE_LOG_LEVEL # Options: DEBUG / INFO / WARNING / ERROR / CRITICAL From 31df4eae8337fe82ba2fe5ba0504a3a00b3a138c Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 12:35:32 +1100 Subject: [PATCH 04/12] Set default environment variables for docker setups --- docker/dev-config.env | 3 +++ docker/prod-config.env | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docker/dev-config.env b/docker/dev-config.env index df65f723c8..b7ee4d8526 100644 --- a/docker/dev-config.env +++ b/docker/dev-config.env @@ -12,3 +12,6 @@ INVENTREE_DB_HOST=inventree-dev-db INVENTREE_DB_PORT=5432 INVENTREE_DB_USER=pguser INVENTREE_DB_PASSWORD=pgpassword + +# Enable plugins? +INVENTREE_PLUGINS_ENABLED=False diff --git a/docker/prod-config.env b/docker/prod-config.env index 55d28e2f96..a69fa10d2a 100644 --- a/docker/prod-config.env +++ b/docker/prod-config.env @@ -14,3 +14,6 @@ INVENTREE_DB_HOST=inventree-db INVENTREE_DB_PORT=5432 INVENTREE_DB_USER=pguser INVENTREE_DB_PASSWORD=pgpassword + +# Enable plugins? +INVENTREE_PLUGINS_ENABLED=False From 8aec055e6c5166e173197ead17254d501b03e5ad Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 13:39:47 +1100 Subject: [PATCH 05/12] Only load plugins if PLUGINS_ENABLED is true - Hide plugin settings - Add plugin support status to "stats" dialog --- .../part/templatetags/inventree_extras.py | 8 ++++++ InvenTree/plugin/apps.py | 25 +++++++++++++------ InvenTree/plugin/events.py | 4 +++ InvenTree/plugin/registry.py | 19 +++++++++++--- .../InvenTree/settings/settings.html | 6 ++++- .../templates/InvenTree/settings/sidebar.html | 9 ++++--- InvenTree/templates/stats.html | 12 +++++++++ 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 9c0b0e691f..fb3a142a9a 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -7,6 +7,7 @@ over and above the built-in Django tags. import os import sys +import django from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ @@ -107,6 +108,13 @@ def inventree_docker_mode(*args, **kwargs): return djangosettings.DOCKER +@register.simple_tag() +def plugins_enabled(*args, **kwargs): + """ Return True if plugins are enabled for the server instance """ + + return djangosettings.PLUGINS_ENABLED + + @register.simple_tag() def inventree_db_engine(*args, **kwargs): """ Return the InvenTree database backend e.g. 'postgresql' """ diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index cca9dee91c..47b5e9f024 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -1,21 +1,32 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import logging + from django.apps import AppConfig +from django.conf import settings + from maintenance_mode.core import set_maintenance_mode from plugin import plugin_registry +logger = logging.getLogger('inventree') + + class PluginAppConfig(AppConfig): name = 'plugin' def ready(self): - if not plugin_registry.is_loading: - # this is the first startup - plugin_registry.collect_plugins() - plugin_registry.load_plugins() - # drop out of maintenance - # makes sure we did not have an error in reloading and maintenance is still active - set_maintenance_mode(False) + if settings.PLUGINS_ENABLED: + logger.info('Loading InvenTree plugins') + + if not plugin_registry.is_loading: + # this is the first startup + plugin_registry.collect_plugins() + plugin_registry.load_plugins() + + # drop out of maintenance + # makes sure we did not have an error in reloading and maintenance is still active + set_maintenance_mode(False) diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index f777621a45..bd1226626e 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -31,6 +31,10 @@ def trigger_event(event, *args, **kwargs): and the worker will respond to it later on. """ + if not settings.PLUGINS_ENABLED: + # Do nothing if plugins are not enabled + return + if not canAppAccessDatabase(): logger.debug(f"Ignoring triggered event '{event}' - database not ready") return diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 8d04620120..448365b338 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -66,6 +66,10 @@ class PluginsRegistry: Load and activate all IntegrationPlugins """ + if not settings.PLUGINS_ENABLED: + # Plugins not enabled, do nothing + return + from plugin.helpers import log_plugin_error logger.info('Start loading plugins') @@ -75,15 +79,16 @@ class PluginsRegistry: if not _maintenance: set_maintenance_mode(True) - registered_sucessfull = False + registered_successful = False blocked_plugin = None retry_counter = settings.PLUGIN_RETRY - while not registered_sucessfull: + + while not registered_successful: 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 + registered_successful = True except (OperationalError, ProgrammingError): # Exception if the database has not been migrated yet logger.info('Database not accessible while loading plugins') @@ -122,6 +127,10 @@ class PluginsRegistry: Unload and deactivate all IntegrationPlugins """ + if not settings.PLUGINS_ENABLED: + # Plugins not enabled, do nothing + return + logger.info('Start unloading plugins') # Set maintanace mode @@ -162,6 +171,10 @@ class PluginsRegistry: Collect integration plugins from all possible ways of loading """ + if not settings.PLUGINS_ENABLED: + # Plugins not enabled, do nothing + return + self.plugin_modules = [] # clear # Collect plugins from paths diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 9b3d2d21de..e559f6ff7f 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -39,14 +39,16 @@ {% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/so.html" %} -{% include "InvenTree/settings/plugin.html" %} +{% if plugins_enabled %} +{% include "InvenTree/settings/plugin.html" %} {% plugin_list as pl_list %} {% for plugin_key, plugin in pl_list.items %} {% if plugin.registered_mixins %} {% include "InvenTree/settings/plugin_settings.html" %} {% endif %} {% endfor %} +{% endif %} {% endif %} @@ -333,9 +335,11 @@ $("#import-part").click(function() { launchModalForm("{% url 'api-part-import' %}?reset", {}); }); +{% if plugins_enabled %} $("#install-plugin").click(function() { installPlugin(); }); +{% endif %} enableSidebar('settings'); diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 24f62f1e1c..281555ff5c 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -47,15 +47,16 @@ {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} +{% if plugins_enabled %} {% include "sidebar_header.html" with text="Plugin Settings" %} - {% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %} {% plugin_list as pl_list %} {% for plugin_key, plugin in pl_list.items %} - {% if plugin.registered_mixins %} - {% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %} - {% endif %} +{% if plugin.registered_mixins %} +{% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %} +{% endif %} {% endfor %} +{% endif %} {% endif %} \ No newline at end of file diff --git a/InvenTree/templates/stats.html b/InvenTree/templates/stats.html index cf3d225cd2..7a22f88023 100644 --- a/InvenTree/templates/stats.html +++ b/InvenTree/templates/stats.html @@ -34,6 +34,18 @@ {% trans "Server is deployed using docker" %} {% endif %} + + + {% trans "Plugin Support" %} + + {% plugins_enabled as p_en %} + {% if p_en %} + {% trans "Plugin support enabled" %} + {% else %} + {% trans "Plugin support disabled" %} + {% endif %} + + {% if user.is_staff %} From 6541dc43ecc8a088c58bfcdb40ae03aa11a8530e Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 13:46:50 +1100 Subject: [PATCH 06/12] Template fixes --- InvenTree/templates/InvenTree/settings/settings.html | 6 ++++-- InvenTree/templates/InvenTree/settings/sidebar.html | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index e559f6ff7f..5fcd18644f 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -40,7 +40,8 @@ {% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/so.html" %} -{% if plugins_enabled %} +{% plugins_enabled as plug %} +{% if plug %} {% include "InvenTree/settings/plugin.html" %} {% plugin_list as pl_list %} {% for plugin_key, plugin in pl_list.items %} @@ -335,7 +336,8 @@ $("#import-part").click(function() { launchModalForm("{% url 'api-part-import' %}?reset", {}); }); -{% if plugins_enabled %} +{% plugins_enabled as plug %} +{% if plug %} $("#install-plugin").click(function() { installPlugin(); }); diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 281555ff5c..b17b226f28 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -47,7 +47,8 @@ {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} -{% if plugins_enabled %} +{% plugins_enabled as plug %} +{% if plug %} {% include "sidebar_header.html" with text="Plugin Settings" %} {% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %} From 1937a9d7377e6a16bdff9df20360bbe5b47f8b80 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 13:49:53 +1100 Subject: [PATCH 07/12] PEP fixes --- InvenTree/part/templatetags/inventree_extras.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index fb3a142a9a..e2faedfd9e 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -7,7 +7,7 @@ over and above the built-in Django tags. import os import sys -import django + from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ @@ -17,6 +17,7 @@ from django import template from django.urls import reverse from django.utils.safestring import mark_safe from django.templatetags.static import StaticNode + from InvenTree import version, settings import InvenTree.helpers From e2bfa175225473418b2ac905e42e0d694817cd16 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 14:15:51 +1100 Subject: [PATCH 08/12] Enable plugin support for github ci pipelines --- .github/workflows/qc_checks.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 598f518f27..b1067c4bbd 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -156,6 +156,7 @@ jobs: env: INVENTREE_DB_NAME: ./inventree.sqlite INVENTREE_DB_ENGINE: sqlite3 + INVENTREE_PLUGINS_ENABLED: true steps: - name: Checkout Code @@ -204,6 +205,7 @@ jobs: INVENTREE_DB_PORT: 5432 INVENTREE_DEBUG: info INVENTREE_CACHE_HOST: localhost + INVENTREE_PLUGINS_ENABLED: true services: postgres: @@ -259,6 +261,7 @@ jobs: INVENTREE_DB_HOST: '127.0.0.1' INVENTREE_DB_PORT: 3306 INVENTREE_DEBUG: info + INVENTREE_PLUGINS_ENABLED: true services: mysql: From 1ddf3c62c48b8a976ff567b73495abdcefb5c2fe Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 14:31:35 +1100 Subject: [PATCH 09/12] CSS fixes for table filter tags --- InvenTree/InvenTree/static/css/inventree.css | 2 ++ InvenTree/templates/js/translated/filters.js | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 317ebaa37b..3f6fffe19b 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -319,6 +319,7 @@ main { display: inline-block; *display: inline; zoom: 1; + padding-top: 3px; padding-left: 3px; padding-right: 3px; border: 1px solid #aaa; @@ -327,6 +328,7 @@ main { margin: 1px; margin-left: 5px; margin-right: 5px; + white-space: nowrap; } .filter-button { diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 7ae8c3e4b4..78ed30eefa 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -305,7 +305,16 @@ function setupFilterList(tableKey, table, target) { var title = getFilterTitle(tableKey, key); var description = getFilterDescription(tableKey, key); - element.append(`
${title} = ${value}x
`); + var filter_tag = ` +
+ ${title} = ${value} + + + +
+ `; + + element.append(filter_tag); } // Callback for reloading the table From 4f79904fc84a8bfc48a9107cfc7b435a4a50efb7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 11 Jan 2022 15:02:32 +1100 Subject: [PATCH 10/12] Ignore events from 'webhook' tables --- InvenTree/plugin/events.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index f777621a45..21c68d0a7c 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -124,6 +124,8 @@ def allow_table_event(table_name): ignore_tables = [ 'common_notificationentry', + 'common_webhookendpoint', + 'common_webhookmessage', ] if table_name in ignore_tables: @@ -140,6 +142,11 @@ def after_save(sender, instance, created, **kwargs): table = sender.objects.model._meta.db_table + instance_id = getattr(instance, 'id', None) + + if instance_id is None: + return + if not allow_table_event(table): return From d92173dc8e358808ff952036ca9a8c74eddba91a Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 12 Jan 2022 09:05:08 +1100 Subject: [PATCH 11/12] Do not service plugin URLs if plugins are not enabled --- InvenTree/InvenTree/urls.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index f7035fb6f1..445dea13ca 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -52,7 +52,14 @@ from users.api import user_urls admin.site.site_header = "InvenTree Admin" -apipatterns = [ +apipatterns = [] + +if settings.PLUGINS_ENABLED: + apipatterns.append( + url(r'^plugin/', include(plugin_api_urls)) + ) + +apipatterns += [ url(r'^barcode/', include(barcode_api_urls)), url(r'^settings/', include(common_api_urls)), url(r'^part/', include(part_api_urls)), @@ -63,7 +70,6 @@ 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)), @@ -159,9 +165,6 @@ frontendpatterns = [ url(r'^search/', SearchView.as_view(), name='search'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), - # plugin urls - get_plugin_urls(), # appends currently loaded plugin urls = None - # admin sites url(r'^admin/error_log/', include('error_report.urls')), url(r'^admin/shell/', include('django_admin_shell.urls')), @@ -180,6 +183,10 @@ frontendpatterns = [ url(r'^accounts/', include('allauth.urls')), # included urlpatterns ] +# Append custom plugin URLs (if plugin support is enabled) +if settings.PLUGINS_ENABLED: + frontendpatterns += get_plugin_urls() + urlpatterns = [ url('', include(frontendpatterns)), url('', include(backendpatterns)), From 36f342f05e37b2bd2add70eebc5e352861950bd6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 12 Jan 2022 09:21:34 +1100 Subject: [PATCH 12/12] URL fix --- 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 445dea13ca..74019838bb 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -185,7 +185,7 @@ frontendpatterns = [ # Append custom plugin URLs (if plugin support is enabled) if settings.PLUGINS_ENABLED: - frontendpatterns += get_plugin_urls() + frontendpatterns.append(get_plugin_urls()) urlpatterns = [ url('', include(frontendpatterns)),