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: diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 1418fddd41..3032b328bd 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -712,3 +712,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/settings.py b/InvenTree/InvenTree/settings.py index 44796efb02..f2654a49cc 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', 'barcodes.plugins', ] 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/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index f7035fb6f1..a1b3a56bcd 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)), @@ -71,6 +77,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'), @@ -159,9 +168,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 +186,10 @@ frontendpatterns = [ url(r'^accounts/', include('allauth.urls')), # included urlpatterns ] +# Append custom plugin URLs (if plugin support is enabled) +if settings.PLUGINS_ENABLED: + frontendpatterns.append(get_plugin_urls()) + urlpatterns = [ url('', include(frontendpatterns)), url('', include(backendpatterns)), 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 4ec6bf9441..65cdea90c5 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 f0d19b3532..e60de3a33f 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 @@ -1391,6 +1399,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 8bd5b6ffba..a02793fb12 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/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 diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 9c0b0e691f..e2faedfd9e 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 + from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ @@ -16,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 @@ -107,6 +109,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 1b0e521488..c297b84ca0 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -1,21 +1,31 @@ # -*- 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 registry +logger = logging.getLogger('inventree') + + class PluginAppConfig(AppConfig): name = 'plugin' def ready(self): - if not registry.is_loading: - # this is the first startup - registry.collect_plugins() - registry.load_plugins() + if settings.PLUGINS_ENABLED: + logger.info('Loading InvenTree 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 not registry.is_loading: + # this is the first startup + registry.collect_plugins() + 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 709a408d71..049c8626c5 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 @@ -124,6 +128,8 @@ def allow_table_event(table_name): ignore_tables = [ 'common_notificationentry', + 'common_webhookendpoint', + 'common_webhookmessage', ] if table_name in ignore_tables: @@ -140,6 +146,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 diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index a65614cf86..d50233782d 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -66,8 +66,9 @@ class PluginsRegistry: """ Load and activate all IntegrationPlugins """ - - from plugin.helpers import log_error + if not settings.PLUGINS_ENABLED: + # Plugins not enabled, do nothing + return logger.info('Start loading plugins') @@ -76,15 +77,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') @@ -123,6 +125,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 @@ -163,6 +169,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..5fcd18644f 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -39,14 +39,17 @@ {% include "InvenTree/settings/build.html" %} {% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/so.html" %} -{% include "InvenTree/settings/plugin.html" %} +{% plugins_enabled as plug %} +{% if plug %} +{% 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 +336,12 @@ $("#import-part").click(function() { launchModalForm("{% url 'api-part-import' %}?reset", {}); }); +{% plugins_enabled as plug %} +{% if plug %} $("#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..b17b226f28 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -47,15 +47,17 @@ {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} +{% 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" %} {% 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/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 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 %} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 490f87f75a..2f73e67955 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -156,6 +156,8 @@ class RuleSet(models.Model): 'common_colortheme', 'common_inventreesetting', 'common_inventreeusersetting', + 'common_webhookendpoint', + 'common_webhookmessage', 'common_notificationentry', 'company_contact', 'users_owner', 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