From 260c51f6fceed99256a9893c6364e59d195d5c9c Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 19 Nov 2021 22:35:28 +0100 Subject: [PATCH 01/97] 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 8e962c0c59980a7382d9282d2a863ed05e148b1c Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Dec 2021 08:03:19 +0100 Subject: [PATCH 02/97] add mixin to consum a single API --- .../plugin/builtin/integration/mixins.py | 72 +++++++++++++++++++ InvenTree/plugin/mixins/__init__.py | 4 +- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 3a6b558db7..70345172ce 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -1,4 +1,7 @@ """default mixins for IntegrationMixins""" +import json +import requests + from django.conf.urls import url, include from plugin.urls import PLUGIN_BASE @@ -167,3 +170,72 @@ class AppMixin: this plugin is always an app with this plugin """ return True + + +class APICallMixin: + """Mixin that enables easier API calls for a plugin + + 1. Add this mixin + 2. Add two global settings for the required url and token/passowrd (use `GlobalSettingsMixin`) + 3. Save the references to `API_URL_SETTING` and `API_PASSWORD_SETTING` + 4. Set `API_TOKEN` to the name required for the token / password by the external API + 5. (Optional) Override the `api_url` property method if some part of the APIs url is static + 6. (Optional) Override `api_headers` to add extra headers (by default the token/password and Content-Type are contained) + 6. Access the API in you plugin code via `api_call` + """ + API_METHOD = 'https' + API_URL_SETTING = None + API_PASSWORD_SETTING = None + + API_TOKEN = 'Bearer' + + class MixinMeta: + """meta options for this mixin""" + MIXIN_NAME = 'external API usage' + + def __init__(self): + super().__init__() + self.add_mixin('api_call', 'has_api_call', __class__) + + @property + def has_api_call(self): + """Is the mixin ready to call external APIs?""" + # TODO check if settings are set + return True + + @property + def api_url(self): + return f'{self.API_METHOD}://{self.get_globalsetting(self.API_URL_SETTING)}' + + @property + def api_headers(self): + return {self.API_TOKEN: self.get_globalsetting(self.API_PASSWORD_SETTING), 'Content-Type': 'application/json'} + + def api_build_url_args(self, arguments): + groups = [] + for key, val in arguments.items(): + groups.append(f'{key}={",".join([str(a) for a in val])}') + return f'?{"&".join(groups)}' + + def api_call(self, endpoint, method: str='GET', url_args=None, data=None, headers=None, simple_response: bool = True): + if url_args: + endpoint += self.api_build_url_args(url_args) + + if headers is None: + headers = self.api_headers + + # build kwargs for call + kwargs = { + 'url': f'{self.api_url}/{endpoint}', + 'headers': headers, + } + if data: + kwargs['data'] = json.dumps(data) + + # run command + response = requests.request(method, **kwargs) + + # return + if simple_response: + return response.json() + return response diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index feb6bc3466..d63bae097b 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -1,6 +1,6 @@ """utility class to enable simpler imports""" -from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin +from ..builtin.integration.mixins import AppMixin, GlobalSettingsMixin, UrlsMixin, NavigationMixin, APICallMixin __all__ = [ - 'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin', + 'AppMixin', 'GlobalSettingsMixin', 'UrlsMixin', 'NavigationMixin', 'APICallMixin', ] From 251fdeb69e161d1c03fb7751fcdab61c49319471 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Dec 2021 18:01:20 +0100 Subject: [PATCH 03/97] PEP fixes --- InvenTree/plugin/builtin/integration/mixins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 70345172ce..932588c281 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -174,7 +174,7 @@ class AppMixin: class APICallMixin: """Mixin that enables easier API calls for a plugin - + 1. Add this mixin 2. Add two global settings for the required url and token/passowrd (use `GlobalSettingsMixin`) 3. Save the references to `API_URL_SETTING` and `API_PASSWORD_SETTING` @@ -217,7 +217,7 @@ class APICallMixin: groups.append(f'{key}={",".join([str(a) for a in val])}') return f'?{"&".join(groups)}' - def api_call(self, endpoint, method: str='GET', url_args=None, data=None, headers=None, simple_response: bool = True): + def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True): if url_args: endpoint += self.api_build_url_args(url_args) From a2871ccb45beebf61d54ed30df91722646fa600d Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 13 Dec 2021 18:40:24 +0100 Subject: [PATCH 04/97] update database images before running --- .github/workflows/qc_checks.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 929a299e93..4491ba7815 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -282,6 +282,7 @@ jobs: cache: 'pip' - name: Install Dependencies run: | + sudo apt-get update sudo apt-get install mysql-server libmysqlclient-dev pip3 install invoke pip3 install mysqlclient From 547db3322fb3f67e3c6f9fbef329c7c6bd73501d Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 1 Jan 2022 22:00:43 +1100 Subject: [PATCH 05/97] Adds new "PluginSetting" class - Adds settings which are unique to a particular plugin --- InvenTree/common/models.py | 168 ++++++++++++++++++------------------- InvenTree/plugin/admin.py | 12 ++- InvenTree/plugin/models.py | 52 ++++++++++-- 3 files changed, 136 insertions(+), 96 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index c514d7c4a9..0babfaa8e3 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -65,13 +65,13 @@ class BaseInvenTreeSetting(models.Model): self.key = str(self.key).upper() - self.clean() + self.clean(**kwargs) self.validate_unique() super().save() @classmethod - def allValues(cls, user=None, exclude_hidden=False): + def allValues(cls, user=None, plugin=None, exclude_hidden=False): """ Return a dict of "all" defined global settings. @@ -82,9 +82,14 @@ class BaseInvenTreeSetting(models.Model): results = cls.objects.all() + # Optionally filter by user if user is not None: results = results.filter(user=user) + # Optionally filter by plugin + if plugin is not None: + results = results.filter(plugin=plugin) + # Query the database settings = {} @@ -123,98 +128,92 @@ class BaseInvenTreeSetting(models.Model): return settings @classmethod - def get_setting_name(cls, key): + def get_setting_definition(cls, key, **kwargs): + """ + Return the 'definition' of a particular settings value, as a dict object. + + - The 'settings' dict can be passed as a kwarg + - If not passed, look for cls.GLOBAL_SETTINGS + - Returns an empty dict if the key is not found + """ + + settings = kwargs.get('settings', cls.GLOBAL_SETTINGS) + + key = str(key).strip().upper() + + if settings is not None and key in settings: + return settings[key] + else: + return {} + + @classmethod + def get_setting_name(cls, key, **kwargs): """ Return the name of a particular setting. If it does not exist, return an empty string. """ - key = str(key).strip().upper() - - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('name', '') - else: - return '' + setting = cls.get_setting_definition(key, **kwargs) + return setting.get('name', '') @classmethod - def get_setting_description(cls, key): + def get_setting_description(cls, key, **kwargs): """ Return the description for a particular setting. If it does not exist, return an empty string. """ - key = str(key).strip().upper() + setting = cls.get_setting_definition(key, **kwargs) - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('description', '') - else: - return '' + return setting.get('description', '') @classmethod - def get_setting_units(cls, key): + def get_setting_units(cls, key, **kwargs): """ Return the units for a particular setting. If it does not exist, return an empty string. """ - key = str(key).strip().upper() + setting = cls.get_setting_definition(key, **kwargs) - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('units', '') - else: - return '' + return setting.get('units', '') @classmethod - def get_setting_validator(cls, key): + def get_setting_validator(cls, key, **kwargs): """ Return the validator for a particular setting. If it does not exist, return None """ - key = str(key).strip().upper() + setting = cls.get_setting_definition(key, **kwargs) - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('validator', None) - else: - return None + return setting.get('validator', None) @classmethod - def get_setting_default(cls, key): + def get_setting_default(cls, key, **kwargs): """ Return the default value for a particular setting. If it does not exist, return an empty string """ - key = str(key).strip().upper() + setting = cls.get_setting_definition(key, **kwargs) - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('default', '') - else: - return '' + return setting.get('default', '') @classmethod - def get_setting_choices(cls, key): + def get_setting_choices(cls, key, **kwargs): """ Return the validator choices available for a particular setting. """ - key = str(key).strip().upper() + setting = cls.get_setting_definition(key, **kwargs) - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - choices = setting.get('choices', None) - else: - choices = None + choices = setting.get('choices', None) if callable(choices): # Evaluate the function (we expect it will return a list of tuples...) @@ -237,8 +236,20 @@ class BaseInvenTreeSetting(models.Model): key = str(key).strip().upper() + settings = cls.objects.all() + + user = kwargs.get('user', None) + + if user is not None: + settings = settings.filter(user=user) + + plugin = kwargs.get('plugin', None) + + if plugin is not None: + settings = settings.filter(plugin=plugin) + try: - setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first() + setting = settings.filter(**cls.get_filters(key, **kwargs)).first() except (ValueError, cls.DoesNotExist): setting = None except (IntegrityError, OperationalError): @@ -247,7 +258,12 @@ class BaseInvenTreeSetting(models.Model): # Setting does not exist! (Try to create it) if not setting: - setting = cls(key=key, value=cls.get_setting_default(key), **kwargs) + # Attempt to create a new settings object + setting = cls( + key=key, + value=cls.get_setting_default(key, **kwargs), + **kwargs + ) try: # Wrap this statement in "atomic", so it can be rolled back if it fails @@ -259,21 +275,6 @@ class BaseInvenTreeSetting(models.Model): return setting - @classmethod - def get_setting_pk(cls, key): - """ - Return the primary-key value for a given setting. - - If the setting does not exist, return None - """ - - setting = cls.get_setting_object(cls) - - if setting: - return setting.pk - else: - return None - @classmethod def get_setting(cls, key, backup_value=None, **kwargs): """ @@ -283,18 +284,19 @@ class BaseInvenTreeSetting(models.Model): # If no backup value is specified, atttempt to retrieve a "default" value if backup_value is None: - backup_value = cls.get_setting_default(key) + backup_value = cls.get_setting_default(key, **kwargs) setting = cls.get_setting_object(key, **kwargs) if setting: value = setting.value - # If the particular setting is defined as a boolean, cast the value to a boolean - if setting.is_bool(): + # Cast to boolean if necessary + if setting.is_bool(**kwargs): value = InvenTree.helpers.str2bool(value) - if setting.is_int(): + # Cast to integer if necessary + if setting.is_int(**kwargs): try: value = int(value) except (ValueError, TypeError): @@ -357,7 +359,7 @@ class BaseInvenTreeSetting(models.Model): def units(self): return self.__class__.get_setting_units(self.key) - def clean(self): + def clean(self, **kwargs): """ If a validator (or multiple validators) are defined for a particular setting key, run them against the 'value' field. @@ -365,7 +367,7 @@ class BaseInvenTreeSetting(models.Model): super().clean() - validator = self.__class__.get_setting_validator(self.key) + validator = self.__class__.get_setting_validator(self.key, **kwargs) if self.is_bool(): self.value = InvenTree.helpers.str2bool(self.value) @@ -459,12 +461,12 @@ class BaseInvenTreeSetting(models.Model): return [opt[0] for opt in choices] - def is_bool(self): + def is_bool(self, **kwargs): """ Check if this setting is required to be a boolean value """ - validator = self.__class__.get_setting_validator(self.key) + validator = self.__class__.get_setting_validator(self.key, **kwargs) return self.__class__.validator_is_bool(validator) @@ -477,15 +479,15 @@ class BaseInvenTreeSetting(models.Model): return InvenTree.helpers.str2bool(self.value) - def setting_type(self): + def setting_type(self, **kwargs): """ Return the field type identifier for this setting object """ - if self.is_bool(): + if self.is_bool(**kwargs): return 'boolean' - elif self.is_int(): + elif self.is_int(**kwargs): return 'integer' else: @@ -504,12 +506,12 @@ class BaseInvenTreeSetting(models.Model): return False - def is_int(self): + def is_int(self, **kwargs): """ Check if the setting is required to be an integer value: """ - validator = self.__class__.get_setting_validator(self.key) + validator = self.__class__.get_setting_validator(self.key, **kwargs) return self.__class__.validator_is_int(validator) @@ -541,17 +543,14 @@ class BaseInvenTreeSetting(models.Model): return value @classmethod - def is_protected(cls, key): + def is_protected(cls, key, **kwargs): """ Check if the setting value is protected """ - key = str(key).strip().upper() + setting = cls.get_setting_definition(key, **kwargs) - if key in cls.GLOBAL_SETTINGS: - return cls.GLOBAL_SETTINGS[key].get('protected', False) - else: - return False + return setting.get('protected', False) def settings_group_options(): @@ -977,13 +976,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'requires_restart': True, }, - 'ENABLE_PLUGINS_GLOBALSETTING': { - 'name': _('Enable global setting integration'), - 'description': _('Enable plugins to integrate into inventree global settings'), - 'default': False, - 'validator': bool, - 'requires_restart': True, - }, 'ENABLE_PLUGINS_APP': { 'name': _('Enable app integration'), 'description': _('Enable plugins to add apps'), diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 3a96a4b9ea..4bd4664212 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -35,12 +35,20 @@ def plugin_deactivate(modeladmin, request, queryset): plugin_update(queryset, False) +class PluginSettingInline(admin.TabularInline): + """ + Inline admin class for PluginSetting + """ + + model = models.PluginSetting + + class PluginConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields""" readonly_fields = ["key", "name", ] - list_display = ['active', '__str__', 'key', 'name', ] + list_display = ['name', 'key', '__str__', 'active', ] list_filter = ['active'] actions = [plugin_activate, plugin_deactivate, ] - + inlines = [PluginSettingInline,] admin.site.register(models.PluginConfig, PluginConfigAdmin) diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 93c6335497..b001798544 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -8,16 +8,17 @@ from __future__ import unicode_literals from django.utils.translation import gettext_lazy as _ from django.db import models +import common.models + from plugin import plugin_reg class PluginConfig(models.Model): - """ A PluginConfig object holds settings for plugins. - - It is used to designate a Part as 'subscribed' for a given User. + """ + A PluginConfig object holds settings for plugins. Attributes: - key: slug of the plugin - must be unique + key: slug of the plugin (this must be unique across all installed plugins!) name: PluginName of the plugin - serves for a manual double check if the right plugin is used active: Should the plugin be loaded? """ @@ -63,7 +64,10 @@ class PluginConfig(models.Model): # functions def __init__(self, *args, **kwargs): - """override to set original state of""" + """ + Override to set original state of the plugin-config instance + """ + super().__init__(*args, **kwargs) self.__org_active = self.active @@ -82,7 +86,9 @@ class PluginConfig(models.Model): } def save(self, force_insert=False, force_update=False, *args, **kwargs): - """extend save method to reload plugins if the 'active' status changes""" + """ + Extend save method to reload plugins if the 'active' status changes + """ reload = kwargs.pop('no_reload', False) # check if no_reload flag is set ret = super().save(force_insert, force_update, *args, **kwargs) @@ -95,3 +101,37 @@ class PluginConfig(models.Model): plugin_reg.reload_plugins() return ret + + +class PluginSetting(common.models.BaseInvenTreeSetting): + """ + This model represents settings for individual plugins + """ + + class Meta: + unique_together = [ + ('plugin', 'key'), + ] + + @classmethod + def get_filters(cls, key, **kwargs): + """ + Override filters method to ensure settings are filtered by plugin id + """ + + filters = super().get_filters(key, **kwargs) + + plugin = kwargs.get('plugin', None) + + if plugin: + filters['plugin'] = plugin + + return filters + + plugin = models.ForeignKey( + PluginConfig, + related_name='settings', + null=False, + verbose_name=_('Plugin'), + on_delete=models.CASCADE, + ) From 7f08c75a08047ccb8a3edbcbe9b06a86f3046d4f Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 1 Jan 2022 22:00:58 +1100 Subject: [PATCH 06/97] Add missing migration file --- .../plugin/migrations/0003_pluginsetting.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 InvenTree/plugin/migrations/0003_pluginsetting.py diff --git a/InvenTree/plugin/migrations/0003_pluginsetting.py b/InvenTree/plugin/migrations/0003_pluginsetting.py new file mode 100644 index 0000000000..83e744fa6b --- /dev/null +++ b/InvenTree/plugin/migrations/0003_pluginsetting.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.10 on 2022-01-01 10:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugin', '0002_alter_pluginconfig_options'), + ] + + operations = [ + migrations.CreateModel( + name='PluginSetting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(help_text='Settings key (must be unique - case insensitive', max_length=50)), + ('value', models.CharField(blank=True, help_text='Settings value', max_length=200)), + ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='plugin.pluginconfig', verbose_name='Plugin')), + ], + options={ + 'unique_together': {('plugin', 'key')}, + }, + ), + ] From 7cb029e7c39773b6567ad9437ff73fc1d04fb6ee Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 1 Jan 2022 22:20:21 +1100 Subject: [PATCH 07/97] Refactor: Rename "GLOBAL_SETTINGS" to "SETTINGS" --- .../management/commands/clean_settings.py | 17 +++++++++++------ InvenTree/common/models.py | 16 ++++++++-------- InvenTree/common/tests.py | 8 ++++---- InvenTree/plugin/admin.py | 5 +++-- InvenTree/plugin/registry.py | 4 ++-- InvenTree/users/models.py | 3 ++- 6 files changed, 30 insertions(+), 23 deletions(-) diff --git a/InvenTree/InvenTree/management/commands/clean_settings.py b/InvenTree/InvenTree/management/commands/clean_settings.py index e0fd09e6c7..283416de29 100644 --- a/InvenTree/InvenTree/management/commands/clean_settings.py +++ b/InvenTree/InvenTree/management/commands/clean_settings.py @@ -2,9 +2,14 @@ Custom management command to cleanup old settings that are not defined anymore """ +import logging + from django.core.management.base import BaseCommand +logger = logging.getLogger('inventree') + + class Command(BaseCommand): """ Cleanup old (undefined) settings in the database @@ -12,27 +17,27 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): - print("Collecting settings") + logger.info("Collecting settings") from common.models import InvenTreeSetting, InvenTreeUserSetting # general settings db_settings = InvenTreeSetting.objects.all() - model_settings = InvenTreeSetting.GLOBAL_SETTINGS + model_settings = InvenTreeSetting.SETTINGS # check if key exist and delete if not for setting in db_settings: if setting.key not in model_settings: setting.delete() - print(f"deleted setting '{setting.key}'") + logger.info(f"deleted setting '{setting.key}'") # user settings db_settings = InvenTreeUserSetting.objects.all() - model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS + model_settings = InvenTreeUserSetting.SETTINGS # check if key exist and delete if not for setting in db_settings: if setting.key not in model_settings: setting.delete() - print(f"deleted user setting '{setting.key}'") + logger.info(f"deleted user setting '{setting.key}'") - print("checked all settings") + logger.info("checked all settings") diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 0babfaa8e3..ecddd17de8 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model): single values (e.g. one-off settings values). """ - GLOBAL_SETTINGS = {} + SETTINGS = {} class Meta: abstract = True @@ -98,13 +98,13 @@ class BaseInvenTreeSetting(models.Model): settings[setting.key.upper()] = setting.value # Specify any "default" values which are not in the database - for key in cls.GLOBAL_SETTINGS.keys(): + for key in cls.SETTINGS.keys(): if key.upper() not in settings: settings[key.upper()] = cls.get_setting_default(key) if exclude_hidden: - hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False) + hidden = cls.SETTINGS[key].get('hidden', False) if hidden: # Remove hidden items @@ -133,11 +133,11 @@ class BaseInvenTreeSetting(models.Model): Return the 'definition' of a particular settings value, as a dict object. - The 'settings' dict can be passed as a kwarg - - If not passed, look for cls.GLOBAL_SETTINGS + - If not passed, look for cls.SETTINGS - Returns an empty dict if the key is not found """ - settings = kwargs.get('settings', cls.GLOBAL_SETTINGS) + settings = kwargs.get('settings', cls.SETTINGS) key = str(key).strip().upper() @@ -594,7 +594,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): The keys must be upper-case """ - GLOBAL_SETTINGS = { + SETTINGS = { 'SERVER_RESTART_REQUIRED': { 'name': _('Restart required'), @@ -1009,7 +1009,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): Return True if this setting requires a server restart after changing """ - options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None) + options = InvenTreeSetting.SETTINGS.get(self.key, None) if options: return options.get('requires_restart', False) @@ -1022,7 +1022,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): An InvenTreeSetting object with a usercontext """ - GLOBAL_SETTINGS = { + SETTINGS = { 'HOMEPAGE_PART_STARRED': { 'name': _('Show subscribed parts'), 'description': _('Show subscribed parts on the homepage'), diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index c20dc5d126..8bd5b6ffba 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -49,9 +49,9 @@ class SettingsTest(TestCase): - Ensure that every global setting has a description. """ - for key in InvenTreeSetting.GLOBAL_SETTINGS.keys(): + for key in InvenTreeSetting.SETTINGS.keys(): - setting = InvenTreeSetting.GLOBAL_SETTINGS[key] + setting = InvenTreeSetting.SETTINGS[key] name = setting.get('name', None) @@ -64,14 +64,14 @@ class SettingsTest(TestCase): raise ValueError(f'Missing GLOBAL_SETTING description for {key}') if not key == key.upper(): - raise ValueError(f"GLOBAL_SETTINGS key '{key}' is not uppercase") + raise ValueError(f"SETTINGS key '{key}' is not uppercase") def test_defaults(self): """ Populate the settings with default values """ - for key in InvenTreeSetting.GLOBAL_SETTINGS.keys(): + for key in InvenTreeSetting.SETTINGS.keys(): value = InvenTreeSetting.get_setting_default(key) diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 4bd4664212..839dac305a 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -40,7 +40,7 @@ class PluginSettingInline(admin.TabularInline): Inline admin class for PluginSetting """ - model = models.PluginSetting + model = models.PluginSetting class PluginConfigAdmin(admin.ModelAdmin): @@ -49,6 +49,7 @@ class PluginConfigAdmin(admin.ModelAdmin): list_display = ['name', 'key', '__str__', 'active', ] list_filter = ['active'] actions = [plugin_activate, plugin_deactivate, ] - inlines = [PluginSettingInline,] + inlines = [PluginSettingInline, ] + admin.site.register(models.PluginConfig, PluginConfigAdmin) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index d858d6c7f0..79892616c4 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -256,7 +256,7 @@ class Plugins: self.mixins_globalsettings[slug] = plugin_setting # Add to settings dir - InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting) + InvenTreeSetting.SETTINGS.update(plugin_setting) def deactivate_integration_globalsettings(self): from common.models import InvenTreeSetting @@ -268,7 +268,7 @@ class Plugins: # remove settings for setting in plugin_settings: - InvenTreeSetting.GLOBAL_SETTINGS.pop(setting) + InvenTreeSetting.SETTINGS.pop(setting) # clear cache self.mixins_globalsettings = {} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 5f2109da82..490f87f75a 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -76,7 +76,8 @@ class RuleSet(models.Model): 'otp_totp_totpdevice', 'otp_static_statictoken', 'otp_static_staticdevice', - 'plugin_pluginconfig' + 'plugin_pluginconfig', + 'plugin_pluginsetting', ], 'part_category': [ 'part_partcategory', From 737467a1fdd79c9f16b6ba00c30415e44dc8d0ac Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 1 Jan 2022 23:14:34 +1100 Subject: [PATCH 08/97] Rename "has_globalsettings" -> "has_settings" --- InvenTree/plugin/builtin/integration/mixins.py | 6 +++--- InvenTree/plugin/test_integration.py | 12 +++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index c7edc8ac36..c3437b7f8d 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -17,11 +17,11 @@ class SettingsMixin: def __init__(self): super().__init__() - self.add_mixin('settings', 'has_globalsettings', __class__) + self.add_mixin('settings', 'has_settings', __class__) self.globalsettings = getattr(self, 'SETTINGS', None) @property - def has_globalsettings(self): + def has_settings(self): """ Does this plugin use custom global settings """ @@ -32,7 +32,7 @@ class SettingsMixin: """ Get patterns for InvenTreeSetting defintion """ - if self.has_globalsettings: + if self.has_settings: return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()} return None diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py index 1371535cfa..5cf00f7c86 100644 --- a/InvenTree/plugin/test_integration.py +++ b/InvenTree/plugin/test_integration.py @@ -23,7 +23,7 @@ class BaseMixinDefinition: class SettingsMixinTest(BaseMixinDefinition, TestCase): MIXIN_HUMAN_NAME = 'Settings' MIXIN_NAME = 'settings' - MIXIN_ENABLE_CHECK = 'has_globalsettings' + MIXIN_ENABLE_CHECK = 'has_settings' TEST_SETTINGS = {'SETTING1': {'default': '123', }} @@ -44,21 +44,15 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase): # settings variable self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS) - # settings pattern - target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()} - self.assertEqual(self.mixin.globalsettingspatterns, target_pattern) - - # no settings - self.assertIsNone(self.mixin_nothing.globalsettings) - self.assertIsNone(self.mixin_nothing.globalsettingspatterns) - # calling settings # not existing self.assertEqual(self.mixin.get_globalsetting('ABCD'), '') self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '') + # right setting self.mixin.set_globalsetting('SETTING1', '12345', self.test_user) self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345') + # no setting self.assertEqual(self.mixin_nothing.get_globalsetting(''), '') From f3bfe6e7ca1c61e9971519540722d51fd81f60e6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 2 Jan 2022 11:21:06 +1100 Subject: [PATCH 09/97] More refactoring: - Rename "mixins_globalsettings" to "mixing_settings" - Fix translation on settings page template --- InvenTree/plugin/admin.py | 12 +++-- .../plugin/builtin/integration/mixins.py | 53 +++++++++++-------- InvenTree/plugin/integration.py | 8 +-- InvenTree/plugin/plugin.py | 24 ++++++++- InvenTree/plugin/registry.py | 10 ++-- .../plugin/samples/integration/sample.py | 9 ++++ .../plugin/templatetags/plugin_extras.py | 6 +-- InvenTree/plugin/test_integration.py | 10 ++-- InvenTree/plugin/test_plugin.py | 6 +-- .../InvenTree/settings/mixins/settings.html | 2 +- .../templates/InvenTree/settings/setting.html | 4 +- 11 files changed, 91 insertions(+), 53 deletions(-) diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 839dac305a..f13444f274 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -4,8 +4,7 @@ from __future__ import unicode_literals from django.contrib import admin import plugin.models as models -from plugin import plugin_reg - +import plugin.registry as registry def plugin_update(queryset, new_status: bool): """general function for bulk changing plugins""" @@ -20,7 +19,7 @@ def plugin_update(queryset, new_status: bool): # reload plugins if they changed if apps_changed: - plugin_reg.reload_plugins() + registry.plugin_registry.reload_plugins() @admin.action(description='Activate plugin(s)') @@ -42,6 +41,13 @@ class PluginSettingInline(admin.TabularInline): model = models.PluginSetting + read_only_fields = [ + 'key', + ] + + def has_add_permission(self, request, obj): + return False + class PluginConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields""" diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index c3437b7f8d..47e9ff630d 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -3,7 +3,9 @@ Plugin mixin classes """ from django.conf.urls import url, include +from django.db.utils import OperationalError, ProgrammingError +from plugin.models import PluginConfig, PluginSetting from plugin.urls import PLUGIN_BASE @@ -18,43 +20,48 @@ class SettingsMixin: def __init__(self): super().__init__() self.add_mixin('settings', 'has_settings', __class__) - self.globalsettings = getattr(self, 'SETTINGS', None) + self.settings = getattr(self, 'SETTINGS', {}) @property def has_settings(self): """ Does this plugin use custom global settings """ - return bool(self.globalsettings) + return bool(self.settings) - @property - def globalsettingspatterns(self): + def get_setting(self, key): """ - Get patterns for InvenTreeSetting defintion + Return the 'value' of the setting associated with this plugin """ - if self.has_settings: - return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()} - return None - def _globalsetting_name(self, key): - """ - Get global name of setting - """ - return f'PLUGIN_{self.slug.upper()}_{key}' + # Find the plugin configuration associated with this plugin - def get_globalsetting(self, key): - """ - get plugin global setting by key - """ - from common.models import InvenTreeSetting - return InvenTreeSetting.get_setting(self._globalsetting_name(key)) + try: + plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) + except (OperationalError, ProgrammingError) as error: + plugin = None + + if not plugin: + # Plugin cannot be found, return default value + return PluginSetting.get_setting_default(key, settings=self.settings) - def set_globalsetting(self, key, value, user): + return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings) + + def set_setting(self, key, value, user): """ - set plugin global setting by key + Set plugin setting value by key """ - from common.models import InvenTreeSetting - return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user) + + try: + plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) + except (OperationalError, ProgrammingError) as error: + plugin = None + + if not plugin: + # Cannot find associated plugin model, return + return + + PluginSetting.set_setting(key, value, user, plugin=plugin, settings=self.settings) class UrlsMixin: diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index 3cd8ae86d2..d66ab79c26 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -59,8 +59,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): """ The IntegrationPluginBase class is used to integrate with 3rd party software """ - PLUGIN_SLUG = None - PLUGIN_TITLE = None AUTHOR = None DESCRIPTION = None @@ -84,11 +82,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): # region properties @property def slug(self): - """slug for the plugin""" - slug = getattr(self, 'PLUGIN_SLUG', None) - if not slug: - slug = self.plugin_name() - return slugify(slug) + return self.plugin_slug() @property def human_name(self): diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 93199df7b7..dbafda0aea 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -2,6 +2,9 @@ """Base Class for InvenTree plugins""" +from django.utils.text import slugify + + class InvenTreePlugin(): """ Base class for a plugin @@ -10,9 +13,28 @@ class InvenTreePlugin(): # Override the plugin name for each concrete plugin instance PLUGIN_NAME = '' + PLUGIN_SLUG = '' + + PLUGIN_TITLE = '' + def plugin_name(self): - """get plugin name""" + """ + Return the name of this plugin plugin + """ return self.PLUGIN_NAME + def plugin_slug(self): + + slug = getattr(self, 'PLUGIN_SLUG', None) + + if slug is None: + slug = self.plugin_name() + + return slugify(slug) + + def plugin_title(self): + + return self.PLUGIN_TITLE + def __init__(self): pass diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 79892616c4..e0b25ecc65 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -50,7 +50,7 @@ class Plugins: # integration specific self.installed_apps = [] # Holds all added plugin_paths # mixins - self.mixins_globalsettings = {} + self.mixins_settings = {} # region public plugin functions def load_plugins(self): @@ -252,8 +252,8 @@ class Plugins: logger.info('Registering IntegrationPlugin global settings') for slug, plugin in plugins: if plugin.mixin_enabled('settings'): - plugin_setting = plugin.globalsettingspatterns - self.mixins_globalsettings[slug] = plugin_setting + plugin_setting = plugin.settings + self.mixins_settings[slug] = plugin_setting # Add to settings dir InvenTreeSetting.SETTINGS.update(plugin_setting) @@ -263,7 +263,7 @@ class Plugins: # collect all settings plugin_settings = {} - for _, plugin_setting in self.mixins_globalsettings.items(): + for _, plugin_setting in self.mixins_settings.items(): plugin_settings.update(plugin_setting) # remove settings @@ -271,7 +271,7 @@ class Plugins: InvenTreeSetting.SETTINGS.pop(setting) # clear cache - self.mixins_globalsettings = {} + self.mixins_Fsettings = {} # endregion # region integration_app diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index afc4a8fe8a..682726718f 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -44,6 +44,15 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'default': True, 'validator': bool, }, + 'API_KEY': { + 'name': _('API Key'), + 'description': _('Key required for accessing external API'), + }, + 'NUMERICAL_SETTING': { + 'name': _('Numerical'), + 'description': _('A numerical setting'), + 'validator': int, + }, } NAVIGATION = [ diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index 1b4b269844..b7f4f6b6c1 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -26,9 +26,9 @@ def inactive_plugin_list(*args, **kwargs): @register.simple_tag() -def plugin_globalsettings(plugin, *args, **kwargs): - """ Return a list of all global settings for a plugin """ - return plugin_reg.mixins_globalsettings.get(plugin) +def plugin_settings(plugin, *args, **kwargs): + """ Return a list of all custom settings for a plugin """ + return plugin_reg.mixins_settings.get(plugin) @register.simple_tag() diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py index 5cf00f7c86..f88994384a 100644 --- a/InvenTree/plugin/test_integration.py +++ b/InvenTree/plugin/test_integration.py @@ -46,15 +46,15 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase): # calling settings # not existing - self.assertEqual(self.mixin.get_globalsetting('ABCD'), '') - self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '') + self.assertEqual(self.mixin.get_setting('ABCD'), '') + self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '') # right setting - self.mixin.set_globalsetting('SETTING1', '12345', self.test_user) - self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345') + self.mixin.set_setting('SETTING1', '12345', self.test_user) + self.assertEqual(self.mixin.get_setting('SETTING1'), '12345') # no setting - self.assertEqual(self.mixin_nothing.get_globalsetting(''), '') + self.assertEqual(self.mixin_nothing.get_setting(''), '') class UrlsMixinTest(BaseMixinDefinition, TestCase): diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index 3e0a1967db..b4e2fdaf6f 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -63,11 +63,11 @@ class PluginTagTests(TestCase): """test that all inactive plugins are listed""" self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive) - def test_tag_plugin_globalsettings(self): + def test_tag_plugin_settings(self): """check all plugins are listed""" self.assertEqual( - plugin_tags.plugin_globalsettings(self.sample), - plugin_reg.mixins_globalsettings.get(self.sample) + plugin_tags.plugin_settings(self.sample), + plugin_reg.mixins_settings.get(self.sample) ) def test_tag_mixin_enabled(self): diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html index 910d4bbfbb..cb136b5c4f 100644 --- a/InvenTree/templates/InvenTree/settings/mixins/settings.html +++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html @@ -5,7 +5,7 @@

{% trans "Settings" %}

-{% plugin_globalsettings plugin_key as plugin_settings %} +{% plugin_settings plugin_key as plugin_settings %} diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 7419b7ff34..96a8993798 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -13,7 +13,7 @@ {% endif %} - + diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index e19bba6501..133edfba20 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -28,9 +28,13 @@ function editSetting(pk, options={}) { // Is this a global setting or a user setting? var global = options.global || false; + var plugin = options.plugin; + var url = ''; - if (global) { + if (plugin) { + url = `/api/plugin/settings/${pk}/`; + } else if (global) { url = `/api/settings/global/${pk}/`; } else { url = `/api/settings/user/${pk}/`; From 928b90a83380ba25a6832a5cc8058feba7a8382e Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 4 Jan 2022 21:03:01 +1100 Subject: [PATCH 13/97] Edit plugin settings via the "settings" display --- InvenTree/common/models.py | 17 ++++------------- InvenTree/plugin/models.py | 6 ++++++ .../templates/InvenTree/settings/setting.html | 2 +- .../templates/InvenTree/settings/settings.html | 15 +++++++++++++-- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 048a968d1a..16d0be035a 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -371,23 +371,14 @@ class BaseInvenTreeSetting(models.Model): validator = self.__class__.get_setting_validator(self.key, **kwargs) - if self.is_bool(): - self.value = InvenTree.helpers.str2bool(self.value) - - if self.is_int(): - try: - self.value = int(self.value) - except (ValueError): - raise ValidationError(_('Must be an integer value')) + if validator is not None: + self.run_validator(validator) options = self.valid_options() if options and self.value not in options: raise ValidationError(_("Chosen value is not a valid option")) - if validator is not None: - self.run_validator(validator) - def run_validator(self, validator): """ Run a validator against the 'value' field for this InvenTreeSetting object. @@ -399,7 +390,7 @@ class BaseInvenTreeSetting(models.Model): value = self.value # Boolean validator - if self.is_bool(): + if validator is bool: # Value must "look like" a boolean value if InvenTree.helpers.is_bool(value): # Coerce into either "True" or "False" @@ -410,7 +401,7 @@ class BaseInvenTreeSetting(models.Model): }) # Integer validator - if self.is_int(): + if validator is int: try: # Coerce into an integer value diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index f477df5c27..8b81eb2062 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -113,6 +113,12 @@ class PluginSetting(common.models.BaseInvenTreeSetting): ('plugin', 'key'), ] + def clean(self, **kwargs): + + kwargs['plugin'] = self.plugin + + super().clean(**kwargs) + """ We override the following class methods, so that we can pass the plugin instance diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 91d03849ce..16fc67ef86 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -38,7 +38,7 @@
{% trans setting.name %}{{ setting.name }} {% if setting.is_bool %}
@@ -32,7 +32,7 @@
{% endif %}
- {% trans setting.description %} + {{ setting.description }}
From 993b368d3dded4eaf494b53f12e9e96a111054b3 Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Mon, 3 Jan 2022 23:59:16 +0100 Subject: [PATCH 10/97] new stale check --- .github/workflows/stale.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..c8b6bf3cc7 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,24 @@ +# Marks all issues that do not receive activity stale starting 2022 +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '24 11 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue seems stale. Please react to show this is still important.' + stale-pr-message: 'This PR seems stale. Please react to show this is still important.' + stale-issue-label: 'no-activity' + stale-pr-label: 'no-activity' + start-date: '2022-01-01' From 9b1e9445098f86c0483daf4e59d4a94fedf8cce2 Mon Sep 17 00:00:00 2001 From: Matthias Mair <66015116+matmair@users.noreply.github.com> Date: Tue, 4 Jan 2022 00:04:05 +0100 Subject: [PATCH 11/97] ignore everything that got a milestone this helps keeping false positives lower --- .github/workflows/stale.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c8b6bf3cc7..278e5139e5 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,3 +22,4 @@ jobs: stale-issue-label: 'no-activity' stale-pr-label: 'no-activity' start-date: '2022-01-01' + exempt-all-milestones: true From dc9e25ebad12ee9fe23903d4bd8ac06df033d3b1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 2 Jan 2022 14:12:34 +1100 Subject: [PATCH 12/97] Adds API endpoints for viewing and updating plugin settings A lot of code updates / refactoring here to get this to work as expected --- InvenTree/InvenTree/version.py | 6 +- InvenTree/common/models.py | 26 ++++---- .../part/templatetags/inventree_extras.py | 13 +++- InvenTree/plugin/__init__.py | 8 ++- InvenTree/plugin/admin.py | 1 + InvenTree/plugin/api.py | 42 ++++++++++++- InvenTree/plugin/apps.py | 8 +-- .../plugin/builtin/integration/mixins.py | 17 +++-- InvenTree/plugin/helpers.py | 8 +-- InvenTree/plugin/integration.py | 1 - InvenTree/plugin/loader.py | 4 +- InvenTree/plugin/models.py | 62 +++++++++++++++++-- InvenTree/plugin/plugin.py | 30 +++++++-- InvenTree/plugin/registry.py | 23 +++---- .../plugin/samples/integration/sample.py | 12 ++++ InvenTree/plugin/serializers.py | 25 +++++++- .../plugin/templatetags/plugin_extras.py | 10 +-- InvenTree/plugin/test_api.py | 4 +- InvenTree/plugin/test_plugin.py | 10 +-- InvenTree/plugin/urls.py | 4 +- .../InvenTree/settings/mixins/settings.html | 2 +- .../templates/InvenTree/settings/setting.html | 8 ++- InvenTree/templates/js/dynamic/settings.js | 6 +- 23 files changed, 250 insertions(+), 80 deletions(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 79bc44bc0e..1f8e372d39 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 22 +INVENTREE_API_VERSION = 23 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v23 -> 2022-02-02 + - Adds API endpoints for managing plugin classes + - Adds API endpoints for managing plugin settings + v22 -> 2021-12-20 - Adds API endpoint to "merge" multiple stock items diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index ecddd17de8..048a968d1a 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -71,7 +71,7 @@ class BaseInvenTreeSetting(models.Model): super().save() @classmethod - def allValues(cls, user=None, plugin=None, exclude_hidden=False): + def allValues(cls, user=None, exclude_hidden=False): """ Return a dict of "all" defined global settings. @@ -86,10 +86,6 @@ class BaseInvenTreeSetting(models.Model): if user is not None: results = results.filter(user=user) - # Optionally filter by plugin - if plugin is not None: - results = results.filter(plugin=plugin) - # Query the database settings = {} @@ -238,16 +234,12 @@ class BaseInvenTreeSetting(models.Model): settings = cls.objects.all() + # Filter by user user = kwargs.get('user', None) if user is not None: settings = settings.filter(user=user) - plugin = kwargs.get('plugin', None) - - if plugin is not None: - settings = settings.filter(plugin=plugin) - try: setting = settings.filter(**cls.get_filters(key, **kwargs)).first() except (ValueError, cls.DoesNotExist): @@ -255,6 +247,16 @@ class BaseInvenTreeSetting(models.Model): except (IntegrityError, OperationalError): setting = None + plugin = kwargs.pop('plugin', None) + + if plugin: + from plugin import InvenTreePlugin + + if issubclass(plugin.__class__, InvenTreePlugin): + plugin = plugin.plugin_config() + + kwargs['plugin'] = plugin + # Setting does not exist! (Try to create it) if not setting: @@ -554,7 +556,9 @@ class BaseInvenTreeSetting(models.Model): def settings_group_options(): - """build up group tuple for settings based on gour choices""" + """ + Build up group tuple for settings based on your choices + """ return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]] diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 9dc09535fa..9c0b0e691f 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -""" This module provides template tags for extra functionality +""" +This module provides template tags for extra functionality, over and above the built-in Django tags. """ @@ -22,6 +23,8 @@ import InvenTree.helpers from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting from common.settings import currency_code_default +from plugin.models import PluginSetting + register = template.Library() @@ -223,8 +226,16 @@ def setting_object(key, *args, **kwargs): if a user-setting was requested return that """ + if 'plugin' in kwargs: + # Note, 'plugin' is an instance of an InvenTreePlugin class + + plugin = kwargs['plugin'] + + return PluginSetting.get_setting_object(key, plugin=plugin) + if 'user' in kwargs: return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user']) + return InvenTreeSetting.get_setting_object(key) diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py index 973d341171..b3dc3a2fd0 100644 --- a/InvenTree/plugin/__init__.py +++ b/InvenTree/plugin/__init__.py @@ -1,7 +1,11 @@ -from .registry import plugins as plugin_reg +from .registry import plugin_registry +from .plugin import InvenTreePlugin from .integration import IntegrationPluginBase from .action import ActionPlugin __all__ = [ - 'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin', + 'ActionPlugin', + 'IntegrationPluginBase', + 'InvenTreePlugin', + 'plugin_registry', ] diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index f13444f274..e27c499479 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -6,6 +6,7 @@ from django.contrib import admin import plugin.models as models import plugin.registry as registry + def plugin_update(queryset, new_status: bool): """general function for bulk changing plugins""" apps_changed = False diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 4aecd6bb24..9ab3b96724 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -11,7 +11,8 @@ from rest_framework import generics from rest_framework import status from rest_framework.response import Response -from plugin.models import PluginConfig +from common.api import GlobalSettingsPermissions +from plugin.models import PluginConfig, PluginSetting import plugin.serializers as PluginSerializers @@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView): return serializer.save() +class PluginSettingList(generics.ListAPIView): + """ + List endpoint for all plugin related settings. + + - read only + - only accessible by staff users + """ + + queryset = PluginSetting.objects.all() + serializer_class = PluginSerializers.PluginSettingSerializer + + permission_classes = [ + GlobalSettingsPermissions, + ] + + +class PluginSettingDetail(generics.RetrieveUpdateAPIView): + """ + Detail endpoint for a plugin-specific setting. + + Note that these cannot be created or deleted via the API + """ + + queryset = PluginSetting.objects.all() + serializer_class = PluginSerializers.PluginSettingSerializer + + # Staff permission required + permission_classes = [ + GlobalSettingsPermissions, + ] + + plugin_api_urls = [ + + # Plugin settings URLs + url(r'^settings/', include([ + url(r'^(?P\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'), + url(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'), + ])), + # Detail views for a single PluginConfig item url(r'^(?P\d+)/', include([ url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index 8a3cd97889..cca9dee91c 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -4,17 +4,17 @@ from __future__ import unicode_literals from django.apps import AppConfig from maintenance_mode.core import set_maintenance_mode -from plugin.registry import plugins +from plugin import plugin_registry class PluginAppConfig(AppConfig): name = 'plugin' def ready(self): - if not plugins.is_loading: + if not plugin_registry.is_loading: # this is the first startup - plugins.collect_plugins() - plugins.load_plugins() + plugin_registry.collect_plugins() + plugin_registry.load_plugins() # drop out of maintenance # makes sure we did not have an error in reloading and maintenance is still active diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 47e9ff630d..30f3cd8551 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -36,17 +36,14 @@ class SettingsMixin: # Find the plugin configuration associated with this plugin - try: - plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) - except (OperationalError, ProgrammingError) as error: - plugin = None - - if not plugin: + plugin = self.plugin_config() + + if plugin: + return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings) + else: # Plugin cannot be found, return default value return PluginSetting.get_setting_default(key, settings=self.settings) - return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings) - def set_setting(self, key, value, user): """ Set plugin setting value by key @@ -54,12 +51,12 @@ class SettingsMixin: try: plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) - except (OperationalError, ProgrammingError) as error: + except (OperationalError, ProgrammingError): plugin = None if not plugin: # Cannot find associated plugin model, return - return + return PluginSetting.set_setting(key, value, user, plugin=plugin, settings=self.settings) diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index 003ca707b4..fb46df8927 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -10,14 +10,14 @@ from django.conf import settings # region logging / errors def log_plugin_error(error, reference: str = 'general'): - from plugin import plugin_reg + from plugin import plugin_registry # make sure the registry is set up - if reference not in plugin_reg.errors: - plugin_reg.errors[reference] = [] + if reference not in plugin_registry.errors: + plugin_registry.errors[reference] = [] # add error to stack - plugin_reg.errors[reference].append(error) + plugin_registry.errors[reference].append(error) class IntegrationPluginError(Exception): diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index d66ab79c26..2c593291d3 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -9,7 +9,6 @@ import pathlib from django.urls.base import reverse from django.conf import settings -from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ import plugin.plugin as plugin diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py index 2491336a51..2d17f8c36f 100644 --- a/InvenTree/plugin/loader.py +++ b/InvenTree/plugin/loader.py @@ -4,7 +4,7 @@ load templates for loaded plugins from django.template.loaders.filesystem import Loader as FilesystemLoader from pathlib import Path -from plugin import plugin_reg +from plugin import plugin_registry class PluginTemplateLoader(FilesystemLoader): @@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader): def get_dirs(self): dirname = 'templates' template_dirs = [] - for plugin in plugin_reg.plugins.values(): + for plugin in plugin_registry.plugins.values(): new_path = Path(plugin.path) / dirname if Path(new_path).is_dir(): template_dirs.append(new_path) diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index b001798544..f477df5c27 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -10,7 +10,7 @@ from django.db import models import common.models -from plugin import plugin_reg +from plugin import InvenTreePlugin, plugin_registry class PluginConfig(models.Model): @@ -72,7 +72,7 @@ class PluginConfig(models.Model): self.__org_active = self.active # append settings from registry - self.plugin = plugin_reg.plugins.get(self.key, None) + self.plugin = plugin_registry.plugins.get(self.key, None) def get_plugin_meta(name): if self.plugin: @@ -95,10 +95,10 @@ class PluginConfig(models.Model): if not reload: if self.active is False and self.__org_active is True: - plugin_reg.reload_plugins() + plugin_registry.reload_plugins() elif self.active is True and self.__org_active is False: - plugin_reg.reload_plugins() + plugin_registry.reload_plugins() return ret @@ -113,6 +113,58 @@ class PluginSetting(common.models.BaseInvenTreeSetting): ('plugin', 'key'), ] + """ + We override the following class methods, + so that we can pass the plugin instance + """ + + @property + def name(self): + return self.__class__.get_setting_name(self.key, plugin=self.plugin) + + @property + def default_value(self): + return self.__class__.get_setting_default(self.key, plugin=self.plugin) + + @property + def description(self): + return self.__class__.get_setting_description(self.key, plugin=self.plugin) + + @property + def units(self): + return self.__class__.get_setting_units(self.key, plugin=self.plugin) + + def choices(self): + return self.__class__.get_setting_choices(self.key, plugin=self.plugin) + + @classmethod + def get_setting_definition(cls, key, **kwargs): + """ + In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', + which is a dict object that fully defines all the setting parameters. + + Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings + 'ahead of time' (as they are defined externally in the plugins). + + Settings can be provided by the caller, as kwargs['settings']. + + If not provided, we'll look at the plugin registry to see what settings are available, + (if the plugin is specified!) + """ + + if 'settings' not in kwargs: + + plugin = kwargs.pop('plugin', None) + + if plugin: + + if issubclass(plugin.__class__, InvenTreePlugin): + plugin = plugin.plugin_config() + + kwargs['settings'] = plugin_registry.mixins_settings.get(plugin.key, {}) + + return super().get_setting_definition(key, **kwargs) + @classmethod def get_filters(cls, key, **kwargs): """ @@ -124,6 +176,8 @@ class PluginSetting(common.models.BaseInvenTreeSetting): plugin = kwargs.get('plugin', None) if plugin: + if issubclass(plugin.__class__, InvenTreePlugin): + plugin = plugin.plugin_config() filters['plugin'] = plugin return filters diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index dbafda0aea..0cf8082b7f 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- -"""Base Class for InvenTree plugins""" - +""" +Base Class for InvenTree plugins +""" +from django.db.utils import OperationalError, ProgrammingError from django.utils.text import slugify @@ -10,6 +12,9 @@ class InvenTreePlugin(): Base class for a plugin """ + def __init__(self): + pass + # Override the plugin name for each concrete plugin instance PLUGIN_NAME = '' @@ -36,5 +41,22 @@ class InvenTreePlugin(): return self.PLUGIN_TITLE - def __init__(self): - pass + def plugin_config(self, raise_error=False): + """ + Return the PluginConfig object associated with this plugin + """ + + try: + import plugin.models + + cfg, _ = plugin.models.PluginConfig.objects.get_or_create( + key=self.plugin_slug(), + name=self.plugin_name(), + ) + except (OperationalError, ProgrammingError) as error: + cfg = None + + if raise_error: + raise error + + return cfg diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index e0b25ecc65..30dbad5f95 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -33,7 +33,7 @@ from .helpers import get_plugin_error, IntegrationPluginError logger = logging.getLogger('inventree') -class Plugins: +class PluginsRegistry: def __init__(self) -> None: # plugin registry self.plugins = {} @@ -225,7 +225,8 @@ class Plugins: self.plugins_inactive[plug_key] = plugin_db_setting def _activate_plugins(self, force_reload=False): - """run integration functions for all plugins + """ + Run integration functions for all plugins :param force_reload: force reload base apps, defaults to False :type force_reload: bool, optional @@ -238,13 +239,13 @@ class Plugins: self.activate_integration_app(plugins, force_reload=force_reload) def _deactivate_plugins(self): - """run integration deactivation functions for all plugins""" + """ + Run integration deactivation functions for all plugins + """ self.deactivate_integration_app() self.deactivate_integration_globalsettings() # endregion - # region specific integrations - # region integration_globalsettings def activate_integration_globalsettings(self, plugins): from common.models import InvenTreeSetting @@ -255,24 +256,16 @@ class Plugins: plugin_setting = plugin.settings self.mixins_settings[slug] = plugin_setting - # Add to settings dir - InvenTreeSetting.SETTINGS.update(plugin_setting) - def deactivate_integration_globalsettings(self): - from common.models import InvenTreeSetting # collect all settings plugin_settings = {} + for _, plugin_setting in self.mixins_settings.items(): plugin_settings.update(plugin_setting) - # remove settings - for setting in plugin_settings: - InvenTreeSetting.SETTINGS.pop(setting) - # clear cache self.mixins_Fsettings = {} - # endregion # region integration_app def activate_integration_app(self, plugins, force_reload=False): @@ -452,4 +445,4 @@ class Plugins: # endregion -plugins = Plugins() +plugin_registry = PluginsRegistry() diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index 682726718f..a05e804def 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -52,6 +52,18 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'name': _('Numerical'), 'description': _('A numerical setting'), 'validator': int, + 'default': 123, + }, + 'CHOICE_SETTING': { + 'name': _("Choice Setting"), + 'description': _('A setting with multiple choices'), + 'choices': [ + ('A', 'Anaconda'), + ('B', 'Bat'), + ('C', 'Cat'), + ('D', 'Dog'), + ], + 'default': 'A', }, } diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index e25e253498..da171a604d 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -14,7 +14,9 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -from plugin.models import PluginConfig +from common.serializers import SettingsSerializer + +from plugin.models import PluginConfig, PluginSetting class PluginConfigSerializer(serializers.ModelSerializer): @@ -117,3 +119,24 @@ class PluginConfigInstallSerializer(serializers.Serializer): # TODO return ret + + +class PluginSettingSerializer(SettingsSerializer): + """ + Serializer for the PluginSetting model + """ + + plugin = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = PluginSetting + fields = [ + 'pk', + 'key', + 'value', + 'name', + 'description', + 'type', + 'choices', + 'plugin', + ] diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index b7f4f6b6c1..7852133ebb 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -7,7 +7,7 @@ from django import template from django.urls import reverse from common.models import InvenTreeSetting -from plugin import plugin_reg +from plugin import plugin_registry register = template.Library() @@ -16,19 +16,19 @@ register = template.Library() @register.simple_tag() def plugin_list(*args, **kwargs): """ Return a list of all installed integration plugins """ - return plugin_reg.plugins + return plugin_registry.plugins @register.simple_tag() def inactive_plugin_list(*args, **kwargs): """ Return a list of all inactive integration plugins """ - return plugin_reg.plugins_inactive + return plugin_registry.plugins_inactive @register.simple_tag() def plugin_settings(plugin, *args, **kwargs): """ Return a list of all custom settings for a plugin """ - return plugin_reg.mixins_settings.get(plugin) + return plugin_registry.mixins_settings.get(plugin) @register.simple_tag() @@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs): @register.simple_tag() def plugin_errors(*args, **kwargs): """Return all plugin errors""" - return plugin_reg.errors + return plugin_registry.errors diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index 0bb5f7789b..fe1e9802bf 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase): Test the PluginConfig action commands """ from plugin.models import PluginConfig - from plugin import plugin_reg + from plugin import plugin_registry url = reverse('admin:plugin_pluginconfig_changelist') fixtures = PluginConfig.objects.all() # check if plugins were registered -> in some test setups the startup has no db access if not fixtures: - plugin_reg.reload_plugins() + plugin_registry.reload_plugins() fixtures = PluginConfig.objects.all() print([str(a) for a in fixtures]) diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index b4e2fdaf6f..5724bdb0a9 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -8,7 +8,7 @@ from plugin.samples.integration.sample import SampleIntegrationPlugin from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin # from plugin.plugins import load_action_plugins, load_barcode_plugins import plugin.templatetags.plugin_extras as plugin_tags -from plugin import plugin_reg +from plugin import plugin_registry class InvenTreePluginTests(TestCase): @@ -57,17 +57,17 @@ class PluginTagTests(TestCase): def test_tag_plugin_list(self): """test that all plugins are listed""" - self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins) + self.assertEqual(plugin_tags.plugin_list(), plugin_registry.plugins) def test_tag_incative_plugin_list(self): """test that all inactive plugins are listed""" - self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive) + self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_registry.plugins_inactive) def test_tag_plugin_settings(self): """check all plugins are listed""" self.assertEqual( plugin_tags.plugin_settings(self.sample), - plugin_reg.mixins_settings.get(self.sample) + plugin_registry.mixins_settings.get(self.sample) ) def test_tag_mixin_enabled(self): @@ -89,4 +89,4 @@ class PluginTagTests(TestCase): def test_tag_plugin_errors(self): """test that all errors are listed""" - self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors) + self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors) diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py index 419dce5a88..46cdfd8a84 100644 --- a/InvenTree/plugin/urls.py +++ b/InvenTree/plugin/urls.py @@ -3,7 +3,7 @@ URL lookup for plugin app """ from django.conf.urls import url, include -from plugin import plugin_reg +from plugin import plugin_registry PLUGIN_BASE = 'plugin' # Constant for links @@ -12,7 +12,7 @@ PLUGIN_BASE = 'plugin' # Constant for links def get_plugin_urls(): """returns a urlpattern that can be integrated into the global urls""" urls = [] - for plugin in plugin_reg.plugins.values(): + for plugin in plugin_registry.plugins.values(): if plugin.mixin_enabled('urls'): urls.append(plugin.urlpatterns) return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin'))) diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html index cb136b5c4f..57218b3699 100644 --- a/InvenTree/templates/InvenTree/settings/mixins/settings.html +++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html @@ -10,7 +10,7 @@ {% for setting in plugin_settings %} - {% include "InvenTree/settings/setting.html" with key=setting%} + {% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %} {% endfor %}
\ No newline at end of file diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 96a8993798..91d03849ce 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -1,10 +1,12 @@ {% load inventree_extras %} {% load i18n %} -{% if user_setting %} - {% setting_object key user=request.user as setting %} +{% if plugin %} +{% setting_object key plugin=plugin as setting %} +{% elif user_setting %} +{% setting_object key user=request.user as setting %} {% else %} - {% setting_object key as setting %} +{% setting_object key as setting %} {% endif %}
-
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index cb87c6765b..9b3d2d21de 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -62,16 +62,27 @@ $('table').find('.btn-edit-setting').click(function() { var setting = $(this).attr('setting'); var pk = $(this).attr('pk'); - + var plugin = $(this).attr('plugin'); var is_global = true; if ($(this).attr('user')){ is_global = false; } + var title = ''; + + if (plugin != null) { + title = '{% trans "Edit Plugin Setting" %}'; + } else if (is_global) { + title = '{% trans "Edit Global Setting" %}'; + } else { + title = '{% trans "Edit User Setting" %}'; + } + editSetting(pk, { + plugin: plugin, global: is_global, - title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}', + title: title, }); }); From ac849c156664c0867072ae7ae5ee13b22571d15f Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 4 Jan 2022 21:36:27 +1100 Subject: [PATCH 14/97] Fixes for unit tests --- InvenTree/plugin/admin.py | 23 +++-- .../plugin/builtin/integration/mixins.py | 14 +-- InvenTree/plugin/integration.py | 19 +++- InvenTree/plugin/plugin.py | 11 ++- InvenTree/plugin/registry.py | 90 ++++++++++++------- InvenTree/plugin/serializers.py | 9 +- InvenTree/plugin/test_api.py | 4 +- InvenTree/plugin/test_integration.py | 2 +- InvenTree/plugin/test_plugin.py | 5 +- InvenTree/plugin/urls.py | 8 +- 10 files changed, 116 insertions(+), 69 deletions(-) diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index e27c499479..b20aef8057 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -8,30 +8,38 @@ import plugin.registry as registry def plugin_update(queryset, new_status: bool): - """general function for bulk changing plugins""" + """ + General function for bulk changing plugins + """ + apps_changed = False - # run through all plugins in the queryset as the save method needs to be overridden + # Run through all plugins in the queryset as the save method needs to be overridden for plugin in queryset: if plugin.active is not new_status: plugin.active = new_status plugin.save(no_reload=True) apps_changed = True - # reload plugins if they changed + # Reload plugins if they changed if apps_changed: registry.plugin_registry.reload_plugins() @admin.action(description='Activate plugin(s)') def plugin_activate(modeladmin, request, queryset): - """activate a set of plugins""" + """ + Activate a set of plugins + """ plugin_update(queryset, True) @admin.action(description='Deactivate plugin(s)') def plugin_deactivate(modeladmin, request, queryset): - """deactivate a set of plugins""" + """ + Deactivate a set of plugins + """ + plugin_update(queryset, False) @@ -51,7 +59,10 @@ class PluginSettingInline(admin.TabularInline): class PluginConfigAdmin(admin.ModelAdmin): - """Custom admin with restricted id fields""" + """ + Custom admin with restricted id fields + """ + readonly_fields = ["key", "name", ] list_display = ['name', 'key', '__str__', 'active', ] list_filter = ['active'] diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 30f3cd8551..c5d2411e4d 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -34,17 +34,9 @@ class SettingsMixin: Return the 'value' of the setting associated with this plugin """ - # Find the plugin configuration associated with this plugin + return PluginSetting.get_setting(key, plugin=self) - plugin = self.plugin_config() - - if plugin: - return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings) - else: - # Plugin cannot be found, return default value - return PluginSetting.get_setting_default(key, settings=self.settings) - - def set_setting(self, key, value, user): + def set_setting(self, key, value, user=None): """ Set plugin setting value by key """ @@ -58,7 +50,7 @@ class SettingsMixin: # Cannot find associated plugin model, return return - PluginSetting.set_setting(key, value, user, plugin=plugin, settings=self.settings) + PluginSetting.set_setting(key, value, user, plugin=plugin) class UrlsMixin: diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index 2c593291d3..73223593a5 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -19,19 +19,27 @@ logger = logging.getLogger("inventree") class MixinBase: - """general base for mixins""" + """ + General base for mixins + """ def __init__(self) -> None: self._mixinreg = {} self._mixins = {} def add_mixin(self, key: str, fnc_enabled=True, cls=None): - """add a mixin to the plugins registry""" + """ + Add a mixin to the plugins registry + """ + self._mixins[key] = fnc_enabled self.setup_mixin(key, cls=cls) def setup_mixin(self, key, cls=None): - """define mixin details for the current mixin -> provides meta details for all active mixins""" + """ + Define mixin details for the current mixin -> provides meta details for all active mixins + """ + # get human name human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key @@ -43,7 +51,10 @@ class MixinBase: @property def registered_mixins(self, with_base: bool = False): - """get all registered mixins for the plugin""" + """ + Get all registered mixins for the plugin + """ + mixins = getattr(self, '_mixinreg', None) if mixins: # filter out base diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 0cf8082b7f..35643b36c3 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -18,9 +18,9 @@ class InvenTreePlugin(): # Override the plugin name for each concrete plugin instance PLUGIN_NAME = '' - PLUGIN_SLUG = '' + PLUGIN_SLUG = None - PLUGIN_TITLE = '' + PLUGIN_TITLE = None def plugin_name(self): """ @@ -35,11 +35,14 @@ class InvenTreePlugin(): if slug is None: slug = self.plugin_name() - return slugify(slug) + return slugify(slug.lower()) def plugin_title(self): - return self.PLUGIN_TITLE + if self.PLUGIN_TITLE: + return self.PLUGIN_TITLE + else: + return self.plugin_name() def plugin_config(self, raise_error=False): """ diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 30dbad5f95..fe28acfadb 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -1,7 +1,10 @@ """ -registry for plugins -holds the class and the object that contains all code to maintain plugin states +Registry for loading and managing multiple plugins at run-time + +- Holds the class and the object that contains all code to maintain plugin states +- Manages setup and teardown of plugin class instances """ + import importlib import pathlib import logging @@ -34,6 +37,10 @@ logger = logging.getLogger('inventree') class PluginsRegistry: + """ + The PluginsRegistry class + """ + def __init__(self) -> None: # plugin registry self.plugins = {} @@ -54,11 +61,15 @@ class PluginsRegistry: # region public plugin functions def load_plugins(self): - """load and activate all IntegrationPlugins""" + """ + Load and activate all IntegrationPlugins + """ + from plugin.helpers import log_plugin_error logger.info('Start loading plugins') - # set maintanace mode + + # Set maintanace mode _maintenance = bool(get_maintenance_mode()) if not _maintenance: set_maintenance_mode(True) @@ -68,7 +79,7 @@ class PluginsRegistry: retry_counter = settings.PLUGIN_RETRY while not registered_sucessfull: try: - # we are using the db so for migrations etc we need to try this block + # We are using the db so for migrations etc we need to try this block self._init_plugins(blocked_plugin) self._activate_plugins() registered_sucessfull = True @@ -81,13 +92,14 @@ class PluginsRegistry: log_plugin_error({error.path: error.message}, 'load') blocked_plugin = error.path # we will not try to load this app again - # init apps without any integration plugins + # Initialize apps without any integration plugins self._clean_registry() self._clean_installed_apps() self._activate_plugins(force_reload=True) - # we do not want to end in an endless loop + # We do not want to end in an endless loop retry_counter -= 1 + if retry_counter <= 0: if settings.PLUGIN_TESTING: print('[PLUGIN] Max retries, breaking loading') @@ -98,15 +110,20 @@ class PluginsRegistry: # now the loading will re-start up with init - # remove maintenance + # Remove maintenance mode if not _maintenance: set_maintenance_mode(False) + logger.info('Finished loading plugins') def unload_plugins(self): - """unload and deactivate all IntegrationPlugins""" + """ + Unload and deactivate all IntegrationPlugins + """ + logger.info('Start unloading plugins') - # set maintanace mode + + # Set maintanace mode _maintenance = bool(get_maintenance_mode()) if not _maintenance: set_maintenance_mode(True) @@ -123,21 +140,27 @@ class PluginsRegistry: logger.info('Finished unloading plugins') def reload_plugins(self): - """safely reload IntegrationPlugins""" - # do not reload whe currently loading + """ + Safely reload IntegrationPlugins + """ + + # Do not reload whe currently loading if self.is_loading: return logger.info('Start reloading plugins') + with maintenance_mode_on(): self.unload_plugins() self.load_plugins() - logger.info('Finished reloading plugins') - # endregion - # region general plugin managment mechanisms + logger.info('Finished reloading plugins') + def collect_plugins(self): - """collect integration plugins from all possible ways of loading""" + """ + Collect integration plugins from all possible ways of loading + """ + self.plugin_modules = [] # clear # Collect plugins from paths @@ -146,7 +169,7 @@ class PluginsRegistry: if modules: [self.plugin_modules.append(item) for item in modules] - # check if not running in testing mode and apps should be loaded from hooks + # Check if not running in testing mode and apps should be loaded from hooks if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): # Collect plugins from setup entry points for entry in metadata.entry_points().get('inventree_plugins', []): @@ -159,22 +182,25 @@ class PluginsRegistry: logger.info(", ".join([a.__module__ for a in self.plugin_modules])) def _init_plugins(self, disabled=None): - """initialise all found plugins + """ + Initialise all found plugins :param disabled: loading path of disabled app, defaults to None :type disabled: str, optional :raises error: IntegrationPluginError """ + from plugin.models import PluginConfig logger.info('Starting plugin initialisation') + # Initialize integration plugins for plugin in self.plugin_modules: - # check if package + # Check if package was_packaged = getattr(plugin, 'is_package', False) - # check if activated - # these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! + # Check if activated + # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! plug_name = plugin.PLUGIN_NAME plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name plug_key = slugify(plug_key) # keys are slugs! @@ -186,23 +212,23 @@ class PluginsRegistry: raise error plugin_db_setting = None - # always activate if testing + # Always activate if testing if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active): - # check if the plugin was blocked -> threw an error + # Check if the plugin was blocked -> threw an error if disabled: # option1: package, option2: file-based if (plugin.__name__ == disabled) or (plugin.__module__ == disabled): - # errors are bad so disable the plugin in the database + # Errors are bad so disable the plugin in the database if not settings.PLUGIN_TESTING: plugin_db_setting.active = False # TODO save the error to the plugin plugin_db_setting.save(no_reload=True) - # add to inactive plugins so it shows up in the ui + # Add to inactive plugins so it shows up in the ui self.plugins_inactive[plug_key] = plugin_db_setting continue # continue -> the plugin is not loaded - # init package + # Initialize package # now we can be sure that an admin has activated the plugin # TODO check more stuff -> as of Nov 2021 there are not many checks in place # but we could enhance those to check signatures, run the plugin against a whitelist etc. @@ -235,7 +261,7 @@ class PluginsRegistry: plugins = self.plugins.items() logger.info(f'Found {len(plugins)} active plugins') - self.activate_integration_globalsettings(plugins) + self.activate_integration_settings(plugins) self.activate_integration_app(plugins, force_reload=force_reload) def _deactivate_plugins(self): @@ -243,10 +269,9 @@ class PluginsRegistry: Run integration deactivation functions for all plugins """ self.deactivate_integration_app() - self.deactivate_integration_globalsettings() - # endregion + self.deactivate_integration_settings() - def activate_integration_globalsettings(self, plugins): + def activate_integration_settings(self, plugins): from common.models import InvenTreeSetting if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): @@ -256,7 +281,7 @@ class PluginsRegistry: plugin_setting = plugin.settings self.mixins_settings[slug] = plugin_setting - def deactivate_integration_globalsettings(self): + def deactivate_integration_settings(self): # collect all settings plugin_settings = {} @@ -267,7 +292,6 @@ class PluginsRegistry: # clear cache self.mixins_Fsettings = {} - # region integration_app def activate_integration_app(self, plugins, force_reload=False): """activate AppMixin plugins - add custom apps and reload @@ -441,8 +465,6 @@ class PluginsRegistry: return True, [] except Exception as error: get_plugin_error(error, do_raise=True) - # endregion - # endregion plugin_registry = PluginsRegistry() diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index da171a604d..cc999a5a4f 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -1,5 +1,5 @@ """ -JSON serializers for Stock app +JSON serializers for plugin app """ # -*- coding: utf-8 -*- @@ -20,7 +20,8 @@ from plugin.models import PluginConfig, PluginSetting class PluginConfigSerializer(serializers.ModelSerializer): - """ Serializer for a PluginConfig: + """ + Serializer for a PluginConfig: """ meta = serializers.DictField(read_only=True) @@ -73,7 +74,7 @@ class PluginConfigInstallSerializer(serializers.Serializer): if not data.get('confirm'): raise ValidationError({'confirm': _('Installation not confirmed')}) if (not data.get('url')) and (not data.get('packagename')): - msg = _('Either packagenmae of url must be provided') + msg = _('Either packagename of URL must be provided') raise ValidationError({'url': msg, 'packagename': msg}) return data @@ -115,7 +116,7 @@ class PluginConfigInstallSerializer(serializers.Serializer): ret['result'] = str(error.output, 'utf-8') ret['error'] = True - # register plugins + # Register plugins # TODO return ret diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index fe1e9802bf..fdc97e407b 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -8,7 +8,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase class PluginDetailAPITest(InvenTreeAPITestCase): """ - Tests the plugin AP I endpoints + Tests the plugin API endpoints """ roles = [ @@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase): ] def setUp(self): - self.MSG_NO_PKG = 'Either packagenmae of url must be provided' + self.MSG_NO_PKG = 'Either packagename of URL must be provided' self.PKG_NAME = 'minimal' super().setUp() diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py index f88994384a..3d88fed4dd 100644 --- a/InvenTree/plugin/test_integration.py +++ b/InvenTree/plugin/test_integration.py @@ -42,7 +42,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase): def test_function(self): # settings variable - self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS) + self.assertEqual(self.mixin.settings, self.TEST_SETTINGS) # calling settings # not existing diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index 5724bdb0a9..2013ad43c8 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -1,4 +1,6 @@ -""" Unit tests for plugins """ +""" +Unit tests for plugins +""" from django.test import TestCase @@ -6,7 +8,6 @@ import plugin.plugin import plugin.integration from plugin.samples.integration.sample import SampleIntegrationPlugin from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin -# from plugin.plugins import load_action_plugins, load_barcode_plugins import plugin.templatetags.plugin_extras as plugin_tags from plugin import plugin_registry diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py index 46cdfd8a84..1457aaf6f1 100644 --- a/InvenTree/plugin/urls.py +++ b/InvenTree/plugin/urls.py @@ -1,6 +1,7 @@ """ URL lookup for plugin app """ + from django.conf.urls import url, include from plugin import plugin_registry @@ -10,9 +11,14 @@ PLUGIN_BASE = 'plugin' # Constant for links def get_plugin_urls(): - """returns a urlpattern that can be integrated into the global urls""" + """ + Returns a urlpattern that can be integrated into the global urls + """ + urls = [] + for plugin in plugin_registry.plugins.values(): if plugin.mixin_enabled('urls'): urls.append(plugin.urlpatterns) + return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin'))) From 963ac35a5b17cddee6bb27b48291eb5c6474e288 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 4 Jan 2022 21:57:09 +1100 Subject: [PATCH 15/97] Tweaks for settings pages --- .../templates/InvenTree/settings/login.html | 18 +++++++++--------- .../templates/InvenTree/settings/plugin.html | 9 ++++----- .../templates/InvenTree/settings/report.html | 8 ++++---- .../templates/InvenTree/settings/sidebar.html | 2 +- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/InvenTree/templates/InvenTree/settings/login.html b/InvenTree/templates/InvenTree/settings/login.html index b7f32465c6..a52e35fb12 100644 --- a/InvenTree/templates/InvenTree/settings/login.html +++ b/InvenTree/templates/InvenTree/settings/login.html @@ -13,19 +13,19 @@ - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-user-lock" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-user-plus" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-at" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-user-lock" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %} + {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
{% trans 'Signup' %}
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index ac3129eddc..960ec852b8 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -19,10 +19,9 @@
- {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %} - {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %} - {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%} - {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
@@ -70,7 +69,7 @@ {% if mixin_list %} {% for mixin in mixin_list %} - {{ mixin.human_name }} + {{ mixin.human_name }} {% endfor %} {% endif %} diff --git a/InvenTree/templates/InvenTree/settings/report.html b/InvenTree/templates/InvenTree/settings/report.html index 89d26feba6..54c7175508 100644 --- a/InvenTree/templates/InvenTree/settings/report.html +++ b/InvenTree/templates/InvenTree/settings/report.html @@ -12,10 +12,10 @@ - {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %} - {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %} - {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %} - {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 13c370ac16..24f62f1e1c 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -49,7 +49,7 @@ {% include "sidebar_header.html" with text="Plugin Settings" %} -{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %} +{% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %} {% plugin_list as pl_list %} {% for plugin_key, plugin in pl_list.items %} From 960784644fdf0a6e48dee810b9a86fd03d160569 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 10:50:29 +1100 Subject: [PATCH 16/97] Adds skeleton for new API endpoint for completing a build order --- InvenTree/build/api.py | 49 ++++++++++++++++++++++++---------- InvenTree/build/serializers.py | 21 ++++++++++++++- InvenTree/build/test_api.py | 4 +-- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 4d47cf9076..600f738982 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -18,8 +18,7 @@ from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.status_codes import BuildStatus from .models import Build, BuildItem, BuildOrderAttachment -from .serializers import BuildAttachmentSerializer, BuildCompleteSerializer, BuildSerializer, BuildItemSerializer -from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer +import build.serializers from users.models import Owner @@ -80,7 +79,7 @@ class BuildList(generics.ListCreateAPIView): """ queryset = Build.objects.all() - serializer_class = BuildSerializer + serializer_class = build.serializers.BuildSerializer filterset_class = BuildFilter filter_backends = [ @@ -119,7 +118,7 @@ class BuildList(generics.ListCreateAPIView): queryset = super().get_queryset().select_related('part') - queryset = BuildSerializer.annotate_queryset(queryset) + queryset = build.serializers.BuildSerializer.annotate_queryset(queryset) return queryset @@ -203,7 +202,7 @@ class BuildDetail(generics.RetrieveUpdateAPIView): """ API endpoint for detail view of a Build object """ queryset = Build.objects.all() - serializer_class = BuildSerializer + serializer_class = build.serializers.BuildSerializer class BuildUnallocate(generics.CreateAPIView): @@ -217,7 +216,7 @@ class BuildUnallocate(generics.CreateAPIView): queryset = Build.objects.none() - serializer_class = BuildUnallocationSerializer + serializer_class = build.serializers.BuildUnallocationSerializer def get_serializer_context(self): @@ -233,14 +232,14 @@ class BuildUnallocate(generics.CreateAPIView): return ctx -class BuildComplete(generics.CreateAPIView): +class BuildOutputComplete(generics.CreateAPIView): """ API endpoint for completing build outputs """ queryset = Build.objects.none() - serializer_class = BuildCompleteSerializer + serializer_class = build.serializers.BuildOutputCompleteSerializer def get_serializer_context(self): ctx = super().get_serializer_context() @@ -255,6 +254,27 @@ class BuildComplete(generics.CreateAPIView): return ctx +class BuildFinish(generics.CreateAPIView): + """ + API endpoint for marking a build as finished (completed) + """ + + queryset = Build.objects.none() + + serializer_class = build.serializers.BuildCompleteSerializer + + def get_serializer_context(self): + ctx = super().get_serializer_context() + + ctx['request'] = self.request + + try: + ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None)) + except: + pass + + return ctx + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order @@ -269,7 +289,7 @@ class BuildAllocate(generics.CreateAPIView): queryset = Build.objects.none() - serializer_class = BuildAllocationSerializer + serializer_class = build.serializers.BuildAllocationSerializer def get_serializer_context(self): """ @@ -294,7 +314,7 @@ class BuildItemDetail(generics.RetrieveUpdateDestroyAPIView): """ queryset = BuildItem.objects.all() - serializer_class = BuildItemSerializer + serializer_class = build.serializers.BuildItemSerializer class BuildItemList(generics.ListCreateAPIView): @@ -304,7 +324,7 @@ class BuildItemList(generics.ListCreateAPIView): - POST: Create a new BuildItem object """ - serializer_class = BuildItemSerializer + serializer_class = build.serializers.BuildItemSerializer def get_serializer(self, *args, **kwargs): @@ -373,7 +393,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin): """ queryset = BuildOrderAttachment.objects.all() - serializer_class = BuildAttachmentSerializer + serializer_class = build.serializers.BuildAttachmentSerializer filter_backends = [ DjangoFilterBackend, @@ -390,7 +410,7 @@ class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMix """ queryset = BuildOrderAttachment.objects.all() - serializer_class = BuildAttachmentSerializer + serializer_class = build.serializers.BuildAttachmentSerializer build_api_urls = [ @@ -410,7 +430,8 @@ build_api_urls = [ # Build Detail url(r'^(?P\d+)/', include([ url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'), - url(r'^complete/', BuildComplete.as_view(), name='api-build-complete'), + url(r'^complete/', BuildOutputComplete.as_view(), name='api-build-output-complete'), + url(r'^finish/', BuildFinish.as_view(), name='api-build-finish'), url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'), ])), diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 452864e3c4..41b4f84009 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -165,7 +165,7 @@ class BuildOutputSerializer(serializers.Serializer): ] -class BuildCompleteSerializer(serializers.Serializer): +class BuildOutputCompleteSerializer(serializers.Serializer): """ DRF serializer for completing one or more build outputs """ @@ -240,6 +240,25 @@ class BuildCompleteSerializer(serializers.Serializer): ) +class BuildCompleteSerializer(serializers.Serializer): + """ + DRF serializer for marking a BuildOrder as complete + """ + + accept_unallocated = serializers.BooleanField( + label=_('Accept Unallocated'), + help_text=_('Accept that stock items have not been fully allocated to this build order'), + ) + + accept_incomplete = serializers.BooleanField( + label=_('Accept Incomplete'), + help_text=_('Accept that the required number of build outputs have not been completed'), + ) + + def save(self): + pass + + class BuildUnallocationSerializer(serializers.Serializer): """ DRF serializer for unallocating stock from a BuildOrder diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index e2b6448f2f..45662a58d6 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -49,7 +49,7 @@ class BuildCompleteTest(BuildAPITest): self.build = Build.objects.get(pk=1) - self.url = reverse('api-build-complete', kwargs={'pk': self.build.pk}) + self.url = reverse('api-build-output-complete', kwargs={'pk': self.build.pk}) def test_invalid(self): """ @@ -58,7 +58,7 @@ class BuildCompleteTest(BuildAPITest): # Test with an invalid build ID self.post( - reverse('api-build-complete', kwargs={'pk': 99999}), + reverse('api-build-output-complete', kwargs={'pk': 99999}), {}, expected_code=400 ) From ceed90217b66d1043679a06af80cef6d0106d193 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 7 Jan 2022 01:03:05 +0100 Subject: [PATCH 17/97] restructuring --- InvenTree/plugin/serializers.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index e25e253498..f59348601f 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -83,26 +83,28 @@ class PluginConfigInstallSerializer(serializers.Serializer): url = data.get('url', '') # build up the command - command = 'python -m pip install'.split() + install_name = [] if url: # use custom registration / VCS if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]: # using a VCS provider if packagename: - command.append(f'{packagename}@{url}') + install_name.append(f'{packagename}@{url}') else: - command.append(url) + install_name.append(url) else: # using a custom package repositories - command.append('-i') - command.append(url) - command.append(packagename) + install_name.append('-i') + install_name.append(url) + install_name.append(packagename) elif packagename: # use pypi - command.append(packagename) + install_name.append(packagename) + command = 'python -m pip install'.split() + command.extend(install_name) ret = {'command': ' '.join(command)} # execute pypi try: From edc648d61932bd097597f6eff214ab21c0bf7618 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 7 Jan 2022 01:09:44 +0100 Subject: [PATCH 18/97] write installd plugins to plugins.txt --- InvenTree/plugin/serializers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index f59348601f..83c3136620 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -11,10 +11,12 @@ import subprocess from django.core.exceptions import ValidationError from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone from rest_framework import serializers from plugin.models import PluginConfig +from InvenTree.config import get_plugin_file class PluginConfigSerializer(serializers.ModelSerializer): @@ -106,16 +108,20 @@ class PluginConfigInstallSerializer(serializers.Serializer): command = 'python -m pip install'.split() command.extend(install_name) ret = {'command': ' '.join(command)} + success = False # execute pypi try: result = subprocess.check_output(command, cwd=os.path.dirname(settings.BASE_DIR)) ret['result'] = str(result, 'utf-8') ret['success'] = True + success = True except subprocess.CalledProcessError as error: ret['result'] = str(error.output, 'utf-8') ret['error'] = True - # register plugins - # TODO + # save plugin to plugin_file if installed successfull + if success: + with open(get_plugin_file(), "a") as plugin_file: + plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n') return ret From 0974ebb5cd0b3a96eedbaac1bb6047df8885dec5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 7 Jan 2022 01:10:44 +0100 Subject: [PATCH 19/97] shield plugin package load --- InvenTree/plugin/registry.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index d858d6c7f0..1a05a2ed34 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -150,9 +150,12 @@ class Plugins: if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): # Collect plugins from setup entry points for entry in metadata.entry_points().get('inventree_plugins', []): - plugin = entry.load() - plugin.is_package = True - self.plugin_modules.append(plugin) + try: + plugin = entry.load() + plugin.is_package = True + self.plugin_modules.append(plugin) + except Exception as error: + get_plugin_error(error, do_log=True, log_name='discovery') # Log collected plugins logger.info(f'Collected {len(self.plugin_modules)} plugins!') From 12b3a5c9ccc5f3f1b33cffc513d39c685ac5c2e8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 11:33:27 +1100 Subject: [PATCH 20/97] Validate and save the new serailizer --- InvenTree/build/api.py | 1 + InvenTree/build/models.py | 2 +- InvenTree/build/serializers.py | 24 ++++++++- .../build/templates/build/build_base.html | 7 +++ InvenTree/templates/js/translated/build.js | 52 +++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 600f738982..733799f890 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -275,6 +275,7 @@ class BuildFinish(generics.CreateAPIView): return ctx + class BuildAllocate(generics.CreateAPIView): """ API endpoint to allocate stock items to a build order diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 392c773e6b..f03cb30c74 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -555,7 +555,7 @@ class Build(MPTTModel, ReferenceIndexingMixin): if self.incomplete_count > 0: return False - if self.completed < self.quantity: + if self.remaining > 0: return False if not self.areUntrackedPartsFullyAllocated(): diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 41b4f84009..55f89c1844 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -250,13 +250,35 @@ class BuildCompleteSerializer(serializers.Serializer): help_text=_('Accept that stock items have not been fully allocated to this build order'), ) + def validate_accept_unallocated(self, value): + + build = self.context['build'] + + if not build.areUntrackedPartsFullyAllocated() and not value: + raise ValidationError(_('Required stock has not been fully allocated')) + + return value + accept_incomplete = serializers.BooleanField( label=_('Accept Incomplete'), help_text=_('Accept that the required number of build outputs have not been completed'), ) + def validate_accept_incomplete(self, value): + + build = self.context['build'] + + if build.remaining > 0 and not value: + raise ValidationError(_('Required build quantity has not been completed')) + + return value + def save(self): - pass + + request = self.context['request'] + build = self.context['build'] + + build.complete_build(request.user) class BuildUnallocationSerializer(serializers.Serializer): diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 48ef98b2b1..74cd1a9d96 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -224,6 +224,13 @@ src="{% static 'img/blank_image.png' %}" '{% trans "Build Order cannot be completed as incomplete build outputs remain" %}' ); {% else %} + + completeBuildOrder({{ build.pk }}, { + allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %}, + completed: {% if build.remaining == 0 %}true{% else %}false{% endif %}, + }); + + return; launchModalForm( "{% url 'build-complete' build.id %}", { diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 0deec4f859..ebb37fa61c 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -20,6 +20,7 @@ /* exported allocateStockToBuild, + completeBuildOrder, editBuildOrder, loadAllocationTable, loadBuildOrderAllocationTable, @@ -120,6 +121,57 @@ function newBuildOrder(options={}) { } +/* Construct a form to "complete" (finish) a build order */ +function completeBuildOrder(build_id, options={}) { + + var url = `/api/build/${build_id}/finish/`; + + var fields = { + accept_unallocated: {}, + accept_incomplete: {}, + }; + + var html = ''; + + if (options.can_complete) { + + } else { + html += ` +
+ {% trans "Build Order is incomplete" %} +
+ `; + + if (!options.allocated) { + html += `
{% trans "Required stock has not been fully allocated" %}
`; + } + + if (!options.completed) { + html += `
{% trans "Required build quantity has not been completed" %}
`; + } + } + + // Hide particular fields if they are not required + + if (options.allocated) { + delete fields.accept_unallocated; + } + + if (options.completed) { + delete fields.accept_incomplete; + } + + constructForm(url, { + fields: fields, + reload: true, + confirm: true, + method: 'POST', + title: '{% trans "Complete Build Order" %}', + preFormContent: html, + }); +} + + /* * Construct a set of output buttons for a particular build output */ From 2bb1c4ea7767fa567579c44cc2585a6534d6e47e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 11:34:33 +1100 Subject: [PATCH 21/97] Remove old forms / outdated code --- InvenTree/build/forms.py | 18 ---------- .../build/templates/build/build_base.html | 9 ----- InvenTree/build/templates/build/complete.html | 26 --------------- InvenTree/build/urls.py | 1 - InvenTree/build/views.py | 33 ------------------- 5 files changed, 87 deletions(-) delete mode 100644 InvenTree/build/templates/build/complete.html diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py index 19bf3566dc..43899ba819 100644 --- a/InvenTree/build/forms.py +++ b/InvenTree/build/forms.py @@ -83,24 +83,6 @@ class BuildOutputDeleteForm(HelperForm): ] -class CompleteBuildForm(HelperForm): - """ - Form for marking a build as complete - """ - - confirm = forms.BooleanField( - required=True, - label=_('Confirm'), - help_text=_('Mark build as complete'), - ) - - class Meta: - model = Build - fields = [ - 'confirm', - ] - - class CancelBuildForm(HelperForm): """ Form for cancelling a build """ diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 74cd1a9d96..312accb18f 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -229,15 +229,6 @@ src="{% static 'img/blank_image.png' %}" allocated: {% if build.areUntrackedPartsFullyAllocated %}true{% else %}false{% endif %}, completed: {% if build.remaining == 0 %}true{% else %}false{% endif %}, }); - - return; - launchModalForm( - "{% url 'build-complete' build.id %}", - { - reload: true, - submit_text: '{% trans "Complete Build" %}', - } - ); {% endif %} }); diff --git a/InvenTree/build/templates/build/complete.html b/InvenTree/build/templates/build/complete.html deleted file mode 100644 index eeedc027dd..0000000000 --- a/InvenTree/build/templates/build/complete.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{% if build.can_complete %} -
- {% trans "Build Order is complete" %} -
-{% else %} -
- {% trans "Build Order is incomplete" %}
-
    - {% if build.incomplete_count > 0 %} -
  • {% trans "Incompleted build outputs remain" %}
  • - {% endif %} - {% if build.completed < build.quantity %} -
  • {% trans "Required build quantity has not been completed" %}
  • - {% endif %} - {% if not build.areUntrackedPartsFullyAllocated %} -
  • {% trans "Required stock has not been fully allocated" %}
  • - {% endif %} -
-
-{% endif %} -{% endblock %} \ No newline at end of file diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index 8ea339ae26..fecece232e 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -11,7 +11,6 @@ build_detail_urls = [ url(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'), url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'), - url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'), url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), ] diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 1d28cb8d50..1a933af835 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -246,39 +246,6 @@ class BuildOutputDelete(AjaxUpdateView): } -class BuildComplete(AjaxUpdateView): - """ - View to mark the build as complete. - - Requirements: - - There can be no outstanding build outputs - - The "completed" value must meet or exceed the "quantity" value - """ - - model = Build - form_class = forms.CompleteBuildForm - - ajax_form_title = _('Complete Build Order') - ajax_template_name = 'build/complete.html' - - def validate(self, build, form, **kwargs): - - if build.incomplete_count > 0: - form.add_error(None, _('Build order cannot be completed - incomplete outputs remain')) - - def save(self, build, form, **kwargs): - """ - Perform the build completion step - """ - - build.complete_build(self.request.user) - - def get_data(self): - return { - 'success': _('Completed build order') - } - - class BuildDetail(InvenTreeRoleMixin, DetailView): """ Detail view of a single Build object. From aaf35e6c76e2d413ca9c642a561b4058cc218926 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 13:14:47 +1100 Subject: [PATCH 22/97] Customize text if totalRows not known --- InvenTree/templates/js/translated/tables.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index e4b189a074..4c9bec0476 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -371,7 +371,12 @@ function customGroupSorter(sortName, sortOrder, sortData) { return `${pageNumber} {% trans "rows per page" %}`; }, formatShowingRows: function(pageFrom, pageTo, totalRows) { - return `{% trans "Showing" %} ${pageFrom} {% trans "to" %} ${pageTo} {% trans "of" %} ${totalRows} {% trans "rows" %}`; + + if (totalRows === undefined || totalRows === NaN) { + return '{% trans "Showing all rows" %}'; + } else { + return `{% trans "Showing" %} ${pageFrom} {% trans "to" %} ${pageTo} {% trans "of" %} ${totalRows} {% trans "rows" %}`; + } }, formatSearch: function() { return '{% trans "Search" %}'; From 8103b842689c7430dcde809a3ecdd238d3ea4f6e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 14:23:32 +1100 Subject: [PATCH 23/97] Move mixins.py into main plugin directory --- InvenTree/plugin/builtin/integration/__init__.py | 0 InvenTree/plugin/{builtin/integration => }/mixins.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 InvenTree/plugin/builtin/integration/__init__.py rename InvenTree/plugin/{builtin/integration => }/mixins.py (100%) diff --git a/InvenTree/plugin/builtin/integration/__init__.py b/InvenTree/plugin/builtin/integration/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/mixins.py similarity index 100% rename from InvenTree/plugin/builtin/integration/mixins.py rename to InvenTree/plugin/mixins.py From 0773545615654930c66a01decec64aa527c6a809 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 14:54:04 +1100 Subject: [PATCH 24/97] Add "ScheduleMixin" for scheduling tasks --- InvenTree/plugin/mixins.py | 49 ++++++++++++++++++- .../samples/integration/scheduled_tasks.py | 16 ++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 InvenTree/plugin/samples/integration/scheduled_tasks.py diff --git a/InvenTree/plugin/mixins.py b/InvenTree/plugin/mixins.py index c5d2411e4d..580afdc888 100644 --- a/InvenTree/plugin/mixins.py +++ b/InvenTree/plugin/mixins.py @@ -53,6 +53,51 @@ class SettingsMixin: PluginSetting.set_setting(key, value, user, plugin=plugin) +class ScheduleMixin: + """ + Mixin that provides support for scheduled tasks. + + Implementing classes must provide a dict object called SCHEDULED_TASKS, + which provides information on the tasks to be scheduled. + + SCHEDULED_TASKS = { + # Name of the task (will be prepended with the plugin name) + 'test_server': { + 'func': 'myplugin.tasks.test_server', # Python function to call (no arguments!) + 'schedule': "I", # Schedule type (see django_q.Schedule) + 'minutes': 30, # Number of minutes (only if schedule type = Minutes) + 'repeats': 5, # Number of repeats (leave blank for 'forever') + } + } + """ + + SCHEDULED_TASKS = {} + + class MixinMeta: + MIXIN_NAME = 'Schedule' + + def __init__(self): + super().__init__() + self.add_mixin('schedule', 'has_scheduled_tasks', __class__) + self.scheduled_tasks = getattr(self, 'SCHEDULED_TASKS', {}) + + self.validate_scheduled_tasks() + + @property + def has_scheduled_tasks(self): + return bool(self.scheduled_tasks) + + def validate_scheduled_tasks(self): + """ + Check that the provided scheduled tasks are valid + """ + + if not self.has_scheduled_tasks(): + raise ValueError(f"SCHEDULED_TASKS not defined for plugin '{__class__}'") + + for key, task in self.scheduled_tasks.items(): + print(key, task) + class UrlsMixin: """ Mixin that enables custom URLs for the plugin @@ -112,7 +157,9 @@ class NavigationMixin: NAVIGATION_TAB_ICON = "fas fa-question" class MixinMeta: - """meta options for this mixin""" + """ + meta options for this mixin + """ MIXIN_NAME = 'Navigation Links' def __init__(self): diff --git a/InvenTree/plugin/samples/integration/scheduled_tasks.py b/InvenTree/plugin/samples/integration/scheduled_tasks.py new file mode 100644 index 0000000000..14d8399f03 --- /dev/null +++ b/InvenTree/plugin/samples/integration/scheduled_tasks.py @@ -0,0 +1,16 @@ +""" +Sample plugin which supports task scheduling +""" + +from plugin import IntegrationPluginBase +from plugin.mixins import ScheduleMixin + + +class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): + """ + A sample plugin which provides support for scheduled tasks + """ + + PLUGIN_NAME = "ScheduledTasksPlugin" + PLUGIN_SLUG = "schedule" + PLUGIN_TITLE = "A plugin which provides scheduled task support" From 326b897d14f4bd2d6ddacd2d1c2105942283cc7b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 14:54:12 +1100 Subject: [PATCH 25/97] Revert "Move mixins.py into main plugin directory" This reverts commit 8103b842689c7430dcde809a3ecdd238d3ea4f6e. --- InvenTree/plugin/builtin/integration/__init__.py | 0 InvenTree/plugin/{ => builtin/integration}/mixins.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 InvenTree/plugin/builtin/integration/__init__.py rename InvenTree/plugin/{ => builtin/integration}/mixins.py (100%) diff --git a/InvenTree/plugin/builtin/integration/__init__.py b/InvenTree/plugin/builtin/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py similarity index 100% rename from InvenTree/plugin/mixins.py rename to InvenTree/plugin/builtin/integration/mixins.py From 794a9e75e8ed11ce544eacb75fdd64453edc81b4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 15:37:43 +1100 Subject: [PATCH 26/97] Add validation for scheduled tasks defined by a plugin --- InvenTree/common/models.py | 7 ++++++ .../plugin/builtin/integration/mixins.py | 24 ++++++++++++++++--- InvenTree/plugin/integration.py | 4 ++++ InvenTree/plugin/mixins/__init__.py | 8 +++++-- .../samples/integration/scheduled_tasks.py | 23 +++++++++++++++++- .../templates/InvenTree/settings/plugin.html | 3 ++- 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 16d0be035a..15c12f1a36 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -978,6 +978,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'requires_restart': True, }, + 'ENABLE_PLUGINS_SCHEDULE': { + 'name': _('Enable schedule integration'), + 'description': _('Enable plugins to run scheduled tasks'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + } } class Meta: diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 580afdc888..9be1598fe7 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -69,8 +69,12 @@ class ScheduleMixin: 'repeats': 5, # Number of repeats (leave blank for 'forever') } } + + Note: 'schedule' parameter must be one of ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] """ + ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + SCHEDULED_TASKS = {} class MixinMeta: @@ -92,11 +96,25 @@ class ScheduleMixin: Check that the provided scheduled tasks are valid """ - if not self.has_scheduled_tasks(): - raise ValueError(f"SCHEDULED_TASKS not defined for plugin '{__class__}'") + if not self.has_scheduled_tasks: + raise ValueError(f"SCHEDULED_TASKS not defined") for key, task in self.scheduled_tasks.items(): - print(key, task) + + if 'func' not in task: + raise ValueError(f"Task '{key}' is missing 'func' parameter") + + if 'schedule' not in task: + raise ValueError(f"Task '{key}' is missing 'schedule' parameter") + + schedule = task['schedule'].upper().strip() + + if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: + raise ValueError(f"Task '{key}': Schedule '{schedule}' is not a valid option") + + # If 'minutes' is selected, it must be provided! + if schedule == 'I' and 'minutes' not in task: + raise ValueError(f"Task '{key}' is missing 'minutes' parameter") class UrlsMixin: """ diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index 73223593a5..b7ae7d1fc4 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -94,6 +94,10 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): def slug(self): return self.plugin_slug() + @property + def name(self): + return self.plugin_name() + @property def human_name(self): """human readable name for labels etc.""" diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index ceb5de5885..e9c910bb9e 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -1,9 +1,13 @@ -"""utility class to enable simpler imports""" -from ..builtin.integration.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin +""" +Utility class to enable simpler imports +""" + +from ..builtin.integration.mixins import AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin __all__ = [ 'AppMixin', 'NavigationMixin', + 'ScheduleMixin', 'SettingsMixin', 'UrlsMixin', ] diff --git a/InvenTree/plugin/samples/integration/scheduled_tasks.py b/InvenTree/plugin/samples/integration/scheduled_tasks.py index 14d8399f03..04672ebed3 100644 --- a/InvenTree/plugin/samples/integration/scheduled_tasks.py +++ b/InvenTree/plugin/samples/integration/scheduled_tasks.py @@ -6,6 +6,15 @@ from plugin import IntegrationPluginBase from plugin.mixins import ScheduleMixin +# Define some simple tasks to perform +def print_hello(): + print("Hello") + + +def print_world(): + print("World") + + class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): """ A sample plugin which provides support for scheduled tasks @@ -13,4 +22,16 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): PLUGIN_NAME = "ScheduledTasksPlugin" PLUGIN_SLUG = "schedule" - PLUGIN_TITLE = "A plugin which provides scheduled task support" + PLUGIN_TITLE = "Scheduled Tasks" + + SCHEDULED_TASKS = { + 'hello': { + 'func': 'plugin.builtin.integration.mixins.ScheduleMixin.print_hello', + 'schedule': 'I', + 'minutes': 5, + }, + 'world': { + 'func': 'plugin.builtin.integration.mixins.ScheduleMixin.print_world', + 'schedule': 'H', + } + } \ No newline at end of file diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index 960ec852b8..858d0f3ab9 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -19,6 +19,7 @@
+ {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} @@ -28,7 +29,7 @@
-

{% trans "Plugin list" %}

+

{% trans "Plugins" %}

{% include "spacer.html" %}
{% url 'admin:plugin_pluginconfig_changelist' as url %} From 0ab9b2dbc76889d1fb53021b3798cc65a4888377 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 15:41:31 +1100 Subject: [PATCH 27/97] Bug fix - always allow plugins to register settings --- InvenTree/plugin/registry.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index fe28acfadb..f01f452607 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -272,14 +272,15 @@ class PluginsRegistry: self.deactivate_integration_settings() def activate_integration_settings(self, plugins): - from common.models import InvenTreeSetting - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): - logger.info('Registering IntegrationPlugin global settings') - for slug, plugin in plugins: - if plugin.mixin_enabled('settings'): - plugin_setting = plugin.settings - self.mixins_settings[slug] = plugin_setting + logger.info('Registering IntegrationPlugin global settings') + + self.mixins_settings = {} + + for slug, plugin in plugins: + if plugin.mixin_enabled('settings'): + plugin_setting = plugin.settings + self.mixins_settings[slug] = plugin_setting def deactivate_integration_settings(self): @@ -290,7 +291,7 @@ class PluginsRegistry: plugin_settings.update(plugin_setting) # clear cache - self.mixins_Fsettings = {} + self.mixins_settings = {} def activate_integration_app(self, plugins, force_reload=False): """activate AppMixin plugins - add custom apps and reload From ff598a22ffdf8bbd8a57f0a7cc18d9b1626a7743 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 16:20:16 +1100 Subject: [PATCH 28/97] bug fix : correct setting name when changing a 'requires restart' setting --- InvenTree/common/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 15c12f1a36..dee0eb2e8b 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -571,7 +571,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): super().save() if self.requires_restart(): - InvenTreeSetting.set_setting('SERVER_REQUIRES_RESTART', True, None) + InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None) """ Dict of all global settings values: From 3eb1fa32f9b808c5861eb300e78bbcdee4b3c537 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 16:51:00 +1100 Subject: [PATCH 29/97] Scheduled tasks get registered for the background worker --- .../plugin/builtin/integration/mixins.py | 57 +++++++++++++++++++ InvenTree/plugin/registry.py | 54 +++++++++++++++++- .../{scheduled_tasks.py => scheduled_task.py} | 4 +- 3 files changed, 110 insertions(+), 5 deletions(-) rename InvenTree/plugin/samples/integration/{scheduled_tasks.py => scheduled_task.py} (79%) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 9be1598fe7..ca3af01e53 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -2,6 +2,8 @@ Plugin mixin classes """ +import logging + from django.conf.urls import url, include from django.db.utils import OperationalError, ProgrammingError @@ -9,6 +11,9 @@ from plugin.models import PluginConfig, PluginSetting from plugin.urls import PLUGIN_BASE +logger = logging.getLogger('inventree') + + class SettingsMixin: """ Mixin that enables global settings for the plugin @@ -116,6 +121,58 @@ class ScheduleMixin: if schedule == 'I' and 'minutes' not in task: raise ValueError(f"Task '{key}' is missing 'minutes' parameter") + def get_task_name(self, key): + # Generate a 'unique' task name + slug = self.plugin_slug() + return f"plugin.{slug}.{key}" + + def get_task_names(self): + # Returns a list of all task names associated with this plugin instance + return [self.get_task_name(key) for key in self.scheduled_tasks.keys()] + + def register_tasks(self): + """ + Register the tasks with the database + """ + + from django_q.models import Schedule + + for key, task in self.scheduled_tasks.items(): + + task_name = self.get_task_name(key) + + # If a matching scheduled task does not exist, create it! + if not Schedule.objects.filter(name=task_name).exists(): + + logger.info(f"Adding scheduled task '{task_name}'") + + Schedule.objects.create( + name=task_name, + func=task['func'], + schedule_type=task['schedule'], + minutes=task.get('minutes', None), + repeats=task.get('repeats', -1), + ) + + + def unregister_tasks(self): + """ + Deregister the tasks with the database + """ + + from django_q.models import Schedule + + for key, task in self.scheduled_tasks.items(): + + task_name = self.get_task_name(key) + + try: + scheduled_task = Schedule.objects.get(name=task_name) + scheduled_task.delete() + except Schedule.DoesNotExist: + pass + + class UrlsMixin: """ Mixin that enables custom URLs for the plugin diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index f01f452607..86e31608e7 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -262,18 +262,21 @@ class PluginsRegistry: logger.info(f'Found {len(plugins)} active plugins') self.activate_integration_settings(plugins) + self.activate_integration_schedule(plugins) self.activate_integration_app(plugins, force_reload=force_reload) def _deactivate_plugins(self): """ Run integration deactivation functions for all plugins """ + self.deactivate_integration_app() + self.deactivate_integration_schedule() self.deactivate_integration_settings() def activate_integration_settings(self, plugins): - logger.info('Registering IntegrationPlugin global settings') + logger.info('Activating plugin settings') self.mixins_settings = {} @@ -293,8 +296,50 @@ class PluginsRegistry: # clear cache self.mixins_settings = {} + def activate_integration_schedule(self, plugins): + + logger.info('Activating plugin tasks') + + from common.models import InvenTreeSetting + from django_q.models import Schedule + + # List of tasks we have activated + task_keys = [] + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'): + + for slug, plugin in plugins: + + if plugin.mixin_enabled('schedule'): + config = plugin.plugin_config() + + # Only active tasks for plugins which are enabled + if config and config.active: + plugin.register_tasks() + task_keys += plugin.get_task_names() + + logger.info(f"Activated {len(task_keys)} scheduled tasks") + + # Remove any scheduled tasks which do not match + # This stops 'old' plugin tasks from accumulating + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + + deleted_count = 0 + + for task in scheduled_plugin_tasks: + if task.name not in task_keys: + task.delete() + deleted_count += 1 + + if deleted_count > 0: + logger.info(f"Removed {deleted_count} old scheduled tasks") + + def deactivate_integration_schedule(self): + pass + def activate_integration_app(self, plugins, force_reload=False): - """activate AppMixin plugins - add custom apps and reload + """ + Activate AppMixin plugins - add custom apps and reload :param plugins: list of IntegrationPlugins that should be installed :type plugins: dict @@ -378,7 +423,10 @@ class PluginsRegistry: return plugin_path def deactivate_integration_app(self): - """deactivate integration app - some magic required""" + """ + Deactivate integration app - some magic required + """ + # unregister models from admin for plugin_path in self.installed_apps: models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed diff --git a/InvenTree/plugin/samples/integration/scheduled_tasks.py b/InvenTree/plugin/samples/integration/scheduled_task.py similarity index 79% rename from InvenTree/plugin/samples/integration/scheduled_tasks.py rename to InvenTree/plugin/samples/integration/scheduled_task.py index 04672ebed3..fb84c03503 100644 --- a/InvenTree/plugin/samples/integration/scheduled_tasks.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -26,12 +26,12 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): SCHEDULED_TASKS = { 'hello': { - 'func': 'plugin.builtin.integration.mixins.ScheduleMixin.print_hello', + 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'I', 'minutes': 5, }, 'world': { - 'func': 'plugin.builtin.integration.mixins.ScheduleMixin.print_world', + 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'H', } } \ No newline at end of file From 36feef65584c73f57969c8da7cde014e0e443251 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 16:53:51 +1100 Subject: [PATCH 30/97] Remove log message if not relevent --- InvenTree/plugin/registry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 86e31608e7..7e08ce3efb 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -318,7 +318,8 @@ class PluginsRegistry: plugin.register_tasks() task_keys += plugin.get_task_names() - logger.info(f"Activated {len(task_keys)} scheduled tasks") + if len(task_keys) > 0: + logger.info(f"Activated {len(task_keys)} scheduled tasks") # Remove any scheduled tasks which do not match # This stops 'old' plugin tasks from accumulating From c04e07c1fa391726d318677f8a390985b5f0aa9f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 17:04:33 +1100 Subject: [PATCH 31/97] Add a task which fails on purpose --- InvenTree/plugin/builtin/integration/mixins.py | 9 ++++----- InvenTree/plugin/registry.py | 6 +++--- .../plugin/samples/integration/scheduled_task.py | 12 ++++++++++-- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index ca3af01e53..54f739d04d 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -102,16 +102,16 @@ class ScheduleMixin: """ if not self.has_scheduled_tasks: - raise ValueError(f"SCHEDULED_TASKS not defined") + raise ValueError("SCHEDULED_TASKS not defined") for key, task in self.scheduled_tasks.items(): - + if 'func' not in task: raise ValueError(f"Task '{key}' is missing 'func' parameter") - + if 'schedule' not in task: raise ValueError(f"Task '{key}' is missing 'schedule' parameter") - + schedule = task['schedule'].upper().strip() if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: @@ -153,7 +153,6 @@ class ScheduleMixin: minutes=task.get('minutes', None), repeats=task.get('repeats', -1), ) - def unregister_tasks(self): """ diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 7e08ce3efb..12dfc9d43e 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -299,7 +299,7 @@ class PluginsRegistry: def activate_integration_schedule(self, plugins): logger.info('Activating plugin tasks') - + from common.models import InvenTreeSetting from django_q.models import Schedule @@ -307,7 +307,7 @@ class PluginsRegistry: task_keys = [] if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'): - + for slug, plugin in plugins: if plugin.mixin_enabled('schedule'): @@ -427,7 +427,7 @@ class PluginsRegistry: """ Deactivate integration app - some magic required """ - + # unregister models from admin for plugin_path in self.installed_apps: models = [] # the modelrefs need to be collected as poping an item in a iter is not welcomed diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index fb84c03503..5a8f866cd7 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -15,6 +15,10 @@ def print_world(): print("World") +def fail_task(): + raise ValueError("This task should fail!") + + class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): """ A sample plugin which provides support for scheduled tasks @@ -33,5 +37,9 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): 'world': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'H', - } - } \ No newline at end of file + }, + 'failure': { + 'func': 'plugin.samples.integration.scheduled_task.fail_task', + 'schedule': 'D', + }, + } From 103dfaa2a57e1d1f5a635ed128d0b0725c90d709 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 17:11:53 +1100 Subject: [PATCH 32/97] try/catch for operational error - Database might not yet be ready to load models --- .../plugin/builtin/integration/mixins.py | 50 +++++++++++-------- InvenTree/plugin/registry.py | 23 +++++---- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 54f739d04d..cd757edc9f 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -135,41 +135,49 @@ class ScheduleMixin: Register the tasks with the database """ - from django_q.models import Schedule + try: + from django_q.models import Schedule - for key, task in self.scheduled_tasks.items(): + for key, task in self.scheduled_tasks.items(): - task_name = self.get_task_name(key) + task_name = self.get_task_name(key) - # If a matching scheduled task does not exist, create it! - if not Schedule.objects.filter(name=task_name).exists(): + # If a matching scheduled task does not exist, create it! + if not Schedule.objects.filter(name=task_name).exists(): - logger.info(f"Adding scheduled task '{task_name}'") + logger.info(f"Adding scheduled task '{task_name}'") - Schedule.objects.create( - name=task_name, - func=task['func'], - schedule_type=task['schedule'], - minutes=task.get('minutes', None), - repeats=task.get('repeats', -1), - ) + Schedule.objects.create( + name=task_name, + func=task['func'], + schedule_type=task['schedule'], + minutes=task.get('minutes', None), + repeats=task.get('repeats', -1), + ) + except OperationalError: + # Database might not yet be ready + pass def unregister_tasks(self): """ Deregister the tasks with the database """ - from django_q.models import Schedule + try: + from django_q.models import Schedule - for key, task in self.scheduled_tasks.items(): + for key, task in self.scheduled_tasks.items(): - task_name = self.get_task_name(key) + task_name = self.get_task_name(key) - try: - scheduled_task = Schedule.objects.get(name=task_name) - scheduled_task.delete() - except Schedule.DoesNotExist: - pass + try: + scheduled_task = Schedule.objects.get(name=task_name) + scheduled_task.delete() + except Schedule.DoesNotExist: + pass + except OperationalError: + # Database might not yet be ready + pass class UrlsMixin: diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 12dfc9d43e..37196d7e54 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -301,7 +301,6 @@ class PluginsRegistry: logger.info('Activating plugin tasks') from common.models import InvenTreeSetting - from django_q.models import Schedule # List of tasks we have activated task_keys = [] @@ -323,17 +322,23 @@ class PluginsRegistry: # Remove any scheduled tasks which do not match # This stops 'old' plugin tasks from accumulating - scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") + try: + from django_q.models import Schedule - deleted_count = 0 + scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.") - for task in scheduled_plugin_tasks: - if task.name not in task_keys: - task.delete() - deleted_count += 1 + deleted_count = 0 - if deleted_count > 0: - logger.info(f"Removed {deleted_count} old scheduled tasks") + for task in scheduled_plugin_tasks: + if task.name not in task_keys: + task.delete() + deleted_count += 1 + + if deleted_count > 0: + logger.info(f"Removed {deleted_count} old scheduled tasks") + except OperationalError: + # Database might not yet be ready + pass def deactivate_integration_schedule(self): pass From 668e2bfcd593d06d9e4441a1e4ebc50af1238b9b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 17:20:57 +1100 Subject: [PATCH 33/97] Further error catching --- InvenTree/plugin/builtin/integration/mixins.py | 4 ++-- InvenTree/plugin/registry.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index cd757edc9f..5390740c03 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -154,7 +154,7 @@ class ScheduleMixin: minutes=task.get('minutes', None), repeats=task.get('repeats', -1), ) - except OperationalError: + except (ProgrammingError, OperationalError): # Database might not yet be ready pass @@ -175,7 +175,7 @@ class ScheduleMixin: scheduled_task.delete() except Schedule.DoesNotExist: pass - except OperationalError: + except (ProgrammingError, OperationalError): # Database might not yet be ready pass diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 37196d7e54..c6dbe959b8 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -336,7 +336,7 @@ class PluginsRegistry: if deleted_count > 0: logger.info(f"Removed {deleted_count} old scheduled tasks") - except OperationalError: + except (ProgrammingError, OperationalError): # Database might not yet be ready pass From 8efd45f0adc4e9766c7b64f9e565371c162ad077 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 7 Jan 2022 18:00:38 +1100 Subject: [PATCH 34/97] log warning message if db not ready --- InvenTree/plugin/builtin/integration/mixins.py | 4 ++-- InvenTree/plugin/registry.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 5390740c03..c6198ed7a1 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -156,7 +156,7 @@ class ScheduleMixin: ) except (ProgrammingError, OperationalError): # Database might not yet be ready - pass + logger.warning("register_tasks failed, database not ready") def unregister_tasks(self): """ @@ -177,7 +177,7 @@ class ScheduleMixin: pass except (ProgrammingError, OperationalError): # Database might not yet be ready - pass + logger.warning("unregister_tasks failed, database not ready") class UrlsMixin: diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index c6dbe959b8..45df8cf94b 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -338,7 +338,7 @@ class PluginsRegistry: logger.info(f"Removed {deleted_count} old scheduled tasks") except (ProgrammingError, OperationalError): # Database might not yet be ready - pass + logger.warning("activate_integration_schedule failed, database not ready") def deactivate_integration_schedule(self): pass From 1e5ecb13f04213f05bd0805ce6d169c5e7187cfa Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 7 Jan 2022 21:48:17 +1100 Subject: [PATCH 35/97] Add code for triggering and responding to events --- InvenTree/InvenTree/events.py | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 InvenTree/InvenTree/events.py diff --git a/InvenTree/InvenTree/events.py b/InvenTree/InvenTree/events.py new file mode 100644 index 0000000000..75b04adb00 --- /dev/null +++ b/InvenTree/InvenTree/events.py @@ -0,0 +1,42 @@ +""" +Functions for triggering and responding to server side events +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging + +from InvenTree.tasks import offload_task + + +logger = logging.getLogger('inventree') + + +def trigger_event(event, *args, **kwargs): + """ + Trigger an even with optional arguments. + + This event will be stored in the database, + and the worker will respond to it later on. + """ + + logger.debug(f"Event triggered: '{event}'") + + offload_task( + 'InvenTree.events.process_event', + event, + *args, + **kwargs, + ) + +def process_event(event, *args, **kwargs): + """ + Respond to a triggered event. + + This function is run by the background worker process. + """ + + logger.info(f"Processing event '{event}'") + + # Determine if there are any plugins which are interested in responding From 8ff3bf1ad19cb35ed12975a916b5fbb768c52b1b Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 7 Jan 2022 21:53:42 +1100 Subject: [PATCH 36/97] Adds a new setting to enable event responses --- InvenTree/InvenTree/events.py | 6 ++++++ InvenTree/common/models.py | 11 ++++++++++- InvenTree/templates/InvenTree/settings/plugin.html | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/events.py b/InvenTree/InvenTree/events.py index 75b04adb00..2286f57d7b 100644 --- a/InvenTree/InvenTree/events.py +++ b/InvenTree/InvenTree/events.py @@ -7,6 +7,10 @@ from __future__ import unicode_literals import logging +from django.conf import settings + +from common.models import InvenTreeSetting + from InvenTree.tasks import offload_task @@ -40,3 +44,5 @@ def process_event(event, *args, **kwargs): logger.info(f"Processing event '{event}'") # Determine if there are any plugins which are interested in responding + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): + pass diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index dee0eb2e8b..d361cb5bb1 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -957,6 +957,8 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': False, 'validator': bool, }, + + # Settings for plugin mixin features 'ENABLE_PLUGINS_URL': { 'name': _('Enable URL integration'), 'description': _('Enable plugins to add URL routes'), @@ -984,7 +986,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': False, 'validator': bool, 'requires_restart': True, - } + }, + 'ENABLE_PLUGINS_EVENTS': { + 'name': _('Enable event integration'), + 'description': _('Enable plugins to respond to internal events'), + 'default': False, + 'validator': bool, + 'requires_restart': True, + }, } class Meta: diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index 858d0f3ab9..7b428e161f 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -20,6 +20,7 @@
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_SCHEDULE" icon="fa-calendar-alt" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_EVENTS" icon="fa-reply-all" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} From 63eb49777a5ce289c3910ea87929702e88990b39 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 7 Jan 2022 22:22:59 +1100 Subject: [PATCH 37/97] Add mixin class to respond to internal events --- .../plugin/builtin/integration/mixins.py | 64 +++++++++++++++++++ InvenTree/plugin/mixins/__init__.py | 3 +- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index c6198ed7a1..68288121e2 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -80,6 +80,7 @@ class ScheduleMixin: ALLOWABLE_SCHEDULE_TYPES = ['I', 'H', 'D', 'W', 'M', 'Q', 'Y'] + # Override this in subclass model SCHEDULED_TASKS = {} class MixinMeta: @@ -180,6 +181,69 @@ class ScheduleMixin: logger.warning("unregister_tasks failed, database not ready") +class EventMixin: + """ + Mixin that provides support for responding to triggered events. + + Implementing classes must provide a list of tuples, + which provide pairs of 'event':'function' + + Notes: + + Events are called by name, and based on the django signal nomenclature, + e.g. 'part.pre_save' + + Receiving functions must be prototyped to match the 'event' they receive. + + Example: + + EVENTS = [ + ('part.pre_save', 'myplugin.functions.do_stuff'), + ('build.complete', 'myplugin.functions.process_completed_build'), + ] + """ + + # Override this in subclass model + EVENTS = [] + + class MixinMeta: + MIXIN_NAME = 'Events' + + def __init__(self): + super().__init__() + self.add_mixin('events', 'has_events', __class__) + self.events = getattr(self, 'EVENTS', []) + + self.validate_events() + + @property + def has_events(self): + return bool(self.events) and len(self.events) > 0 + + def validate_events(self): + """ + Check that the provided event callbacks are valid + """ + + if not self.has_events: + raise ValueError('EVENTS not defined') + + for pair in self.events: + valid = True + + if len(pair) == 2: + event = pair[0].strip() + func = pair[1].strip() + + if len(event) == 0 or len(func) == 0: + valid = False + else: + valid = False + + if not valid: + raise ValueError("Invalid event callback: " + str(pair)) + + class UrlsMixin: """ Mixin that enables custom URLs for the plugin diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index e9c910bb9e..3df552df75 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -2,10 +2,11 @@ Utility class to enable simpler imports """ -from ..builtin.integration.mixins import AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin +from ..builtin.integration.mixins import AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin __all__ = [ 'AppMixin', + 'EventMixin', 'NavigationMixin', 'ScheduleMixin', 'SettingsMixin', From a604d85f0f83afa413061bb5cb0f14c0ab6805b2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 7 Jan 2022 22:31:10 +1100 Subject: [PATCH 38/97] Move events.py to the plugin app --- InvenTree/{InvenTree => plugin}/events.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename InvenTree/{InvenTree => plugin}/events.py (91%) diff --git a/InvenTree/InvenTree/events.py b/InvenTree/plugin/events.py similarity index 91% rename from InvenTree/InvenTree/events.py rename to InvenTree/plugin/events.py index 2286f57d7b..703fe71d9c 100644 --- a/InvenTree/InvenTree/events.py +++ b/InvenTree/plugin/events.py @@ -19,7 +19,7 @@ logger = logging.getLogger('inventree') def trigger_event(event, *args, **kwargs): """ - Trigger an even with optional arguments. + Trigger an event with optional arguments. This event will be stored in the database, and the worker will respond to it later on. @@ -28,12 +28,13 @@ def trigger_event(event, *args, **kwargs): logger.debug(f"Event triggered: '{event}'") offload_task( - 'InvenTree.events.process_event', + 'plugin.events.process_event', event, *args, **kwargs, ) + def process_event(event, *args, **kwargs): """ Respond to a triggered event. From 04d25a60b0653304ee541192920a2c737a73ac7d Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Jan 2022 09:07:27 +1100 Subject: [PATCH 39/97] Adds sample plugin which responds to triggered events - Adds some example trigger events for the "Part" model --- InvenTree/part/models.py | 12 ++++-- .../plugin/builtin/integration/mixins.py | 4 +- InvenTree/plugin/events.py | 25 +++++++++++- InvenTree/plugin/plugin.py | 12 ++++++ InvenTree/plugin/registry.py | 39 +++++++++++++++++++ .../samples/integration/event_sample.py | 29 ++++++++++++++ .../samples/integration/scheduled_task.py | 2 +- .../InvenTree/settings/plugin_settings.html | 2 +- 8 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 InvenTree/plugin/samples/integration/event_sample.py diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index ec76846a08..99d7cd02f5 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -60,6 +60,8 @@ import common.models import part.settings as part_settings +from plugin.events import trigger_event + logger = logging.getLogger("inventree") @@ -1980,10 +1982,10 @@ class Part(MPTTModel): @property def attachment_count(self): - """ Count the number of attachments for this part. + """ + Count the number of attachments for this part. If the part is a variant of a template part, include the number of attachments for the template part. - """ return self.part_attachments.count() @@ -2181,7 +2183,11 @@ def after_save_part(sender, instance: Part, created, **kwargs): Function to be executed after a Part is saved """ - if not created: + trigger_event('part.saved', part_id=instance.pk) + + if created: + trigger_event('part.created', part_id=instance.pk) + else: # Check part stock only if we are *updating* the part (not creating it) # Run this check in the background diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 68288121e2..dff749af53 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -189,10 +189,10 @@ class EventMixin: which provide pairs of 'event':'function' Notes: - + Events are called by name, and based on the django signal nomenclature, e.g. 'part.pre_save' - + Receiving functions must be prototyped to match the 'event' they receive. Example: diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index 703fe71d9c..bd3bf7c06c 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -8,11 +8,14 @@ from __future__ import unicode_literals import logging from django.conf import settings +from django.db import transaction from common.models import InvenTreeSetting from InvenTree.tasks import offload_task +from plugin.registry import plugin_registry + logger = logging.getLogger('inventree') @@ -38,12 +41,30 @@ def trigger_event(event, *args, **kwargs): def process_event(event, *args, **kwargs): """ Respond to a triggered event. - + This function is run by the background worker process. + + This function may queue multiple functions to be handled by the background worker. """ logger.info(f"Processing event '{event}'") # Determine if there are any plugins which are interested in responding if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): - pass + + # Run atomically, to ensure that either *all* tasks are registered, or *none* + with transaction.atomic(): + for slug, callbacks in plugin_registry.mixins_events.items(): + # slug = plugin slug + # callbacks = list of (event, function) tuples + + for _event, _func in callbacks: + if _event == event: + + logger.info(f"Running task '{_func}' for plugin '{slug}'") + + offload_task( + _func, + *args, + **kwargs + ) diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 35643b36c3..8842553ba7 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -63,3 +63,15 @@ class InvenTreePlugin(): raise error return cfg + + def is_active(self): + """ + Return True if this plugin is currently active + """ + + cfg = self.plugin_config() + + if cfg: + return cfg.active + else: + return False diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index b7e37d22ba..5e6016fc86 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -56,8 +56,10 @@ class PluginsRegistry: # integration specific self.installed_apps = [] # Holds all added plugin_paths + # mixins self.mixins_settings = {} + self.mixins_events = {} # region public plugin functions def load_plugins(self): @@ -265,6 +267,7 @@ class PluginsRegistry: logger.info(f'Found {len(plugins)} active plugins') self.activate_integration_settings(plugins) + self.activate_integration_events(plugins) self.activate_integration_schedule(plugins) self.activate_integration_app(plugins, force_reload=force_reload) @@ -275,6 +278,7 @@ class PluginsRegistry: self.deactivate_integration_app() self.deactivate_integration_schedule() + self.deactivate_integration_events() self.deactivate_integration_settings() def activate_integration_settings(self, plugins): @@ -299,6 +303,41 @@ class PluginsRegistry: # clear cache self.mixins_settings = {} + def activate_integration_events(self, plugins): + """ + Activate triggered events mixin for applicable plugins + """ + + logger.info('Activating plugin events') + + from common.models import InvenTreeSetting + + self.mixins_events = {} + + event_count = 0 + + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): + + for slug, plugin in plugins: + + if plugin.mixin_enabled('events'): + config = plugin.plugin_config() + + # Only activate events for plugins which are enabled + if config and config.active: + self.mixins_events[slug] = plugin.events + + event_count += len(plugin.events) + + if event_count > 0: + logger.info(f"Registered callbacks for {event_count} events") + + def deactivate_integration_events(self): + """ + Deactivate events for all plugins + """ + self.mixins_events = {} + def activate_integration_schedule(self, plugins): logger.info('Activating plugin tasks') diff --git a/InvenTree/plugin/samples/integration/event_sample.py b/InvenTree/plugin/samples/integration/event_sample.py new file mode 100644 index 0000000000..48516cfa3c --- /dev/null +++ b/InvenTree/plugin/samples/integration/event_sample.py @@ -0,0 +1,29 @@ +""" +Sample plugin which responds to events +""" + +from plugin import IntegrationPluginBase +from plugin.mixins import EventMixin + + +def on_part_saved(*args, **kwargs): + """ + Simple function which responds to a triggered event + """ + + part_id = kwargs['part_id'] + print(f"func on_part_saved() - part: {part_id}") + + +class EventPlugin(EventMixin, IntegrationPluginBase): + """ + A sample plugin which provides supports for triggered events + """ + + PLUGIN_NAME = "EventPlugin" + PLUGIN_SLUG = "event" + PLUGIN_TITLE = "Triggered Events" + + EVENTS = [ + ('part.saved', 'plugin.samples.integration.event_sample.on_part_saved'), + ] diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index 5a8f866cd7..7a08a21287 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -32,7 +32,7 @@ class ScheduledTaskPlugin(ScheduleMixin, IntegrationPluginBase): 'hello': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', 'schedule': 'I', - 'minutes': 5, + 'minutes': 45, }, 'world': { 'func': 'plugin.samples.integration.scheduled_task.print_hello', diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html index 59178300ac..a670d71c34 100644 --- a/InvenTree/templates/InvenTree/settings/plugin_settings.html +++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html @@ -90,7 +90,7 @@ - + From 9e2250e9b80ea1216467db371e42ce37b6430365 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Jan 2022 13:19:16 +1100 Subject: [PATCH 40/97] Bug fixes for settings --- InvenTree/common/models.py | 7 ------- InvenTree/templates/InvenTree/settings/report.html | 2 +- InvenTree/templates/InvenTree/settings/stock.html | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index d361cb5bb1..50c95966cc 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -872,13 +872,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, - 'STOCK_GROUP_BY_PART': { - 'name': _('Group by Part'), - 'description': _('Group stock items by part reference in table views'), - 'default': True, - 'validator': bool, - }, - 'BUILDORDER_REFERENCE_PREFIX': { 'name': _('Build Order Reference Prefix'), 'description': _('Prefix value for build order reference'), diff --git a/InvenTree/templates/InvenTree/settings/report.html b/InvenTree/templates/InvenTree/settings/report.html index 54c7175508..a25ace23ce 100644 --- a/InvenTree/templates/InvenTree/settings/report.html +++ b/InvenTree/templates/InvenTree/settings/report.html @@ -12,7 +12,7 @@
{% trans "Installation path" %} {{ plugin.package_path }}
- {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="fa-file-pdf" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %} diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html index d5455750d7..a3c0940c1f 100644 --- a/InvenTree/templates/InvenTree/settings/stock.html +++ b/InvenTree/templates/InvenTree/settings/stock.html @@ -11,7 +11,6 @@
- {% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %} {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %} {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" icon="fa-truck" %} From 137a668452e802f92f4f64797b28a6bf507f1ed1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Jan 2022 13:29:13 +1100 Subject: [PATCH 41/97] Remove duplicated settings display --- .../templates/InvenTree/settings/user.html | 82 ------------------- 1 file changed, 82 deletions(-) diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index ae04568f53..9bd0dba26e 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -214,88 +214,6 @@ -
-
-

{% trans "Theme Settings" %}

-
- -
-
- {% csrf_token %} - - -
- -
- -
-
- -
-
- -
-
-

{% trans "Language Settings" %}

-
- -
-
- {% csrf_token %} - - -
- -
- -
-

{% trans "Some languages are not complete" %} - {% if ALL_LANG %} - . {% trans "Show only sufficent" %} - {% else %} - and hidden. {% trans "Show them too" %} - {% endif %} -

-
- -
-
-

{% trans "Help the translation efforts!" %}

-

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the - InvenTree web application is community contributed via crowdin. Contributions are - welcomed and encouraged.{% endblocktrans %}

-
-
-
From 77decc72edb2a074d5507d78d0ddbcf2de629cc8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Jan 2022 20:07:54 +1100 Subject: [PATCH 42/97] Extra bug fix for part variant form --- InvenTree/config_template.yaml | 9 +++++---- InvenTree/templates/js/translated/part.js | 8 +++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 3472a37d8e..d8f780bb36 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -14,18 +14,15 @@ database: # --- Available options: --- # ENGINE: Database engine. Selection from: - # - sqlite3 # - mysql # - postgresql + # - sqlite3 # NAME: Database name # USER: Database username (if required) # PASSWORD: Database password (if required) # HOST: Database host address (if required) # PORT: Database host port (if required) - # --- Example Configuration - sqlite3 --- - # ENGINE: sqlite3 - # NAME: '/home/inventree/database.sqlite3' # --- Example Configuration - MySQL --- #ENGINE: mysql @@ -42,6 +39,10 @@ database: #PASSWORD: inventree_password #HOST: 'localhost' #PORT: '5432' + + # --- Example Configuration - sqlite3 --- + # ENGINE: sqlite3 + # NAME: '/home/inventree/database.sqlite3' # Select default system language (default is 'en-us') language: en-us diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 3d70b97598..020c5d1a6c 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -345,6 +345,12 @@ function editPart(pk) { // Launch form to duplicate a part function duplicatePart(pk, options={}) { + var title = '{% trans "Duplicate Part" %}'; + + if (options.variant) { + title = '{% trans "Create Part Variant" %}'; + } + // First we need all the part information inventreeGet(`/api/part/${pk}/`, {}, { @@ -372,7 +378,7 @@ function duplicatePart(pk, options={}) { method: 'POST', fields: fields, groups: partGroups(), - title: '{% trans "Duplicate Part" %}', + title: title, data: data, onSuccess: function(data) { // Follow the new part From af18d16d98407146167bbce92205c1c2045a31af Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Jan 2022 20:19:18 +1100 Subject: [PATCH 43/97] Tweak admin area icon --- InvenTree/templates/navbar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html index 0ee6bb021e..551f1e99ed 100644 --- a/InvenTree/templates/navbar.html +++ b/InvenTree/templates/navbar.html @@ -108,7 +108,7 @@
{% 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 91/97] 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 92/97] 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 93/97] 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 94/97] 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 95/97] 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 96/97] 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 97/97] 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)),
{% trans "Server is deployed using docker" %}
{% trans "Plugin Support" %} + {% plugins_enabled as p_en %} + {% if p_en %} + {% trans "Plugin support enabled" %} + {% else %} + {% trans "Plugin support disabled" %} + {% endif %} +