mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2519
This commit is contained in:
commit
62236474e2
3
.github/workflows/qc_checks.yaml
vendored
3
.github/workflows/qc_checks.yaml
vendored
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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', ]
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)),
|
||||
|
@ -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)
|
||||
|
@ -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/<slug:endpoint>/', 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'),
|
||||
]))
|
||||
])),
|
||||
|
||||
]
|
||||
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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' """
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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 %}
|
@ -305,7 +305,16 @@ function setupFilterList(tableKey, table, target) {
|
||||
var title = getFilterTitle(tableKey, key);
|
||||
var description = getFilterDescription(tableKey, key);
|
||||
|
||||
element.append(`<div title='${description}' class='filter-tag'>${title} = ${value}<span ${tag}='${key}' class='close'>x</span></div>`);
|
||||
var filter_tag = `
|
||||
<div title='${description}' class='filter-tag'>
|
||||
${title} = ${value}
|
||||
<span ${tag}='${key}' class='close' style='color: #F55;'>
|
||||
<span aria-hidden='true'><strong>×</strong></span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
element.append(filter_tag);
|
||||
}
|
||||
|
||||
// Callback for reloading the table
|
||||
|
@ -34,6 +34,18 @@
|
||||
<td>{% trans "Server is deployed using docker" %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-plug'></span></td>
|
||||
<td>{% trans "Plugin Support" %}</td>
|
||||
<td>
|
||||
{% plugins_enabled as p_en %}
|
||||
{% if p_en %}
|
||||
<span class='badge rounded-pill bg-success'>{% trans "Plugin support enabled" %}</span>
|
||||
{% else %}
|
||||
<span class='badge rounded-pill bg-warning'>{% trans "Plugin support disabled" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if user.is_staff %}
|
||||
<tr>
|
||||
<td><span class='fas fa-server'></span></td>
|
||||
|
@ -156,6 +156,8 @@ class RuleSet(models.Model):
|
||||
'common_colortheme',
|
||||
'common_inventreesetting',
|
||||
'common_inventreeusersetting',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'common_notificationentry',
|
||||
'company_contact',
|
||||
'users_owner',
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user