diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index b3ad9e24d2..e73a1e8f98 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -12,7 +12,7 @@ on: - l10* env: - python_version: 3.8 + python_version: 3.9 node_version: 16 server_start_sleep: 60 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 278e5139e5..4f21c1e8be 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: 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' + stale-issue-label: 'inactive' + stale-pr-label: 'inactive' start-date: '2022-01-01' exempt-all-milestones: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index fa2d09c0e7..59f9b3cba9 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v2 - - name: Set up Python 3.7 + - name: Set up Python 3.9 uses: actions/setup-python@v1 with: - python-version: 3.7 + python-version: 3.9 - name: Install Dependencies run: | sudo apt-get update diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index 4501209ff9..5be20aefe7 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -16,5 +16,10 @@ jobs: - uses: actions/first-interaction@v1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: 'Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help.\nIf you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/).' - pr-message: 'This is your first PR, welcome!\nPlease check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow.\nMake sure to document why this PR is needed and to link connected issues so we can review it faster.' + issue-message: | + Welcome to InvenTree! Please check the [contributing docs](https://inventree.readthedocs.io/en/latest/contribute/) on how to help. + If you experience setup / install issues please read all [install docs]( https://inventree.readthedocs.io/en/latest/start/intro/). + pr-message: | + This is your first PR, welcome! + Please check [Contributing](https://github.com/inventree/InvenTree/blob/master/CONTRIBUTING.md) to make sure your submission fits our general code-style and workflow. + Make sure to document why this PR is needed and to link connected issues so we can review it faster. diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index e468e8d1cf..9d901516d5 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -13,15 +13,11 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters from rest_framework import permissions -from rest_framework.response import Response -from rest_framework.views import APIView from .views import AjaxView from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName from .status import is_worker_running -from plugin import registry - class InfoView(AjaxView): """ Simple JSON endpoint for InvenTree information. @@ -119,40 +115,3 @@ class AttachmentMixin: attachment = serializer.save() attachment.user = self.request.user attachment.save() - - -class ActionPluginView(APIView): - """ - Endpoint for running custom action plugins. - """ - - permission_classes = [ - permissions.IsAuthenticated, - ] - - def post(self, request, *args, **kwargs): - - action = request.data.get('action', None) - - data = request.data.get('data', None) - - if action is None: - return Response({ - 'error': _("No action specified") - }) - - action_plugins = registry.with_mixin('action') - for plugin in action_plugins: - if plugin.action_name() == action: - # TODO @matmair use easier syntax once InvenTree 0.7.0 is released - plugin.init(request.user, data=data) - - plugin.perform_action() - - return Response(plugin.get_response()) - - # If we got to here, no matching action was found - return Response({ - 'error': _("No matching action found"), - "action": action, - }) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 3531cc11d7..3a3290f78b 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -197,8 +197,6 @@ class InvenTreeConfig(AppConfig): logger.info(f'User {str(new_user)} was created!') except IntegrityError as _e: logger.warning(f'The user "{add_user}" could not be created due to the following error:\n{str(_e)}') - if settings.TESTING_ENV: - raise _e # do not try again settings.USER_ADDED = True diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 0e813d48de..f55dfbcda2 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -96,6 +96,12 @@ class HTMLAPITests(TestCase): response = self.client.get(url, HTTP_ACCEPT='text/html') self.assertEqual(response.status_code, 200) + def test_not_found(self): + """Test that the NotFoundView is working""" + + response = self.client.get('/api/anc') + self.assertEqual(response.status_code, 404) + class APITests(InvenTreeAPITestCase): """ Tests for the InvenTree API """ diff --git a/InvenTree/InvenTree/test_middleware.py b/InvenTree/InvenTree/test_middleware.py index a622d01193..bced2eb079 100644 --- a/InvenTree/InvenTree/test_middleware.py +++ b/InvenTree/InvenTree/test_middleware.py @@ -32,9 +32,6 @@ class MiddlewareTests(TestCase): # logout self.client.logout() - # check that static files go through - self.check_path('/static/css/inventree.css') - # check that account things go through self.check_path(reverse('account_login')) @@ -62,5 +59,8 @@ class MiddlewareTests(TestCase): # request with token self.check_path(reverse('settings.js'), HTTP_Authorization=f'Token {token}') + # Request with broken token + self.check_path(reverse('settings.js'), 401, HTTP_Authorization='Token abcd123') + # should still fail without token self.check_path(reverse('settings.js'), 401) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index dc3aff85e6..8b2f4c133a 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -451,6 +451,11 @@ class TestSettings(TestCase): self.user_mdl = get_user_model() self.env = EnvironmentVarGuard() + # Create a user for auth + user = get_user_model() + self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1') + self.client.login(username='testuser1', password='password1') + def run_reload(self): from plugin import registry @@ -467,23 +472,49 @@ class TestSettings(TestCase): # nothing set self.run_reload() - self.assertEqual(user_count(), 0) + self.assertEqual(user_count(), 1) # not enough set self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username self.run_reload() - self.assertEqual(user_count(), 0) + self.assertEqual(user_count(), 1) # enough set self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password self.run_reload() - self.assertEqual(user_count(), 1) + self.assertEqual(user_count(), 2) + + # create user manually + self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password') + self.assertEqual(user_count(), 3) + # check it will not be created again + self.env.set('INVENTREE_ADMIN_USER', 'testuser') + self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com') + self.env.set('INVENTREE_ADMIN_PASSWORD', 'password') + self.run_reload() + self.assertEqual(user_count(), 3) # make sure to clean up settings.TESTING_ENV = False + def test_initial_install(self): + """Test if install of plugins on startup works""" + from plugin import registry + + # Check an install run + response = registry.install_plugin_file() + self.assertEqual(response, 'first_run') + + # Set dynamic setting to True and rerun to launch install + InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user) + registry.reload_plugins() + + # Check that there was anotehr run + response = registry.install_plugin_file() + self.assertEqual(response, True) + def test_helpers_cfg_file(self): # normal run - not configured self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file()) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index d408bd7346..ab761c611d 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -27,7 +27,7 @@ from order.api import order_api_urls from label.api import label_api_urls from report.api import report_api_urls from plugin.api import plugin_api_urls -from plugin.barcode import barcode_api_urls +from users.api import user_urls from django.conf import settings from django.conf.urls.static import static @@ -45,20 +45,11 @@ from .views import DynamicJsView from .views import NotificationsView from .api import InfoView, NotFoundView -from .api import ActionPluginView - -from users.api import user_urls admin.site.site_header = "InvenTree Admin" -apipatterns = [] -if settings.PLUGINS_ENABLED: - apipatterns.append( - re_path(r'^plugin/', include(plugin_api_urls)) - ) - -apipatterns += [ +apipatterns = [ re_path(r'^settings/', include(settings_api_urls)), re_path(r'^part/', include(part_api_urls)), re_path(r'^bom/', include(bom_api_urls)), @@ -68,13 +59,10 @@ apipatterns += [ re_path(r'^order/', include(order_api_urls)), re_path(r'^label/', include(label_api_urls)), re_path(r'^report/', include(report_api_urls)), - - # User URLs re_path(r'^user/', include(user_urls)), # Plugin endpoints - re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), - re_path(r'^barcode/', include(barcode_api_urls)), + path('', include(plugin_api_urls)), # Webhook enpoint path('', include(common_api_urls)), diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 2ec83c962c..8b50d05413 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -271,9 +271,9 @@ class BaseInvenTreeSetting(models.Model): plugin = kwargs.get('plugin', None) if plugin is not None: - from plugin import InvenTreePluginBase + from plugin import InvenTreePlugin - if issubclass(plugin.__class__, InvenTreePluginBase): + if issubclass(plugin.__class__, InvenTreePlugin): plugin = plugin.plugin_config() filters['plugin'] = plugin @@ -375,9 +375,9 @@ class BaseInvenTreeSetting(models.Model): filters['user'] = user if plugin is not None: - from plugin import InvenTreePluginBase + from plugin import InvenTreePlugin - if issubclass(plugin.__class__, InvenTreePluginBase): + if issubclass(plugin.__class__, InvenTreePlugin): filters['plugin'] = plugin.plugin_config() else: filters['plugin'] = plugin diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index ef4de4fc61..7f6aece282 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -108,7 +108,7 @@ class NotificationMethod: return False # Check if method globally enabled - plg_instance = registry.plugins.get(plg_cls.PLUGIN_NAME.lower()) + plg_instance = registry.plugins.get(plg_cls.NAME.lower()) if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING): return True diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 50e53ebde0..0e9889c57d 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -10,7 +10,8 @@ from django.urls import reverse from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.helpers import str2bool -from plugin.models import NotificationUserSetting +from plugin.models import NotificationUserSetting, PluginConfig +from plugin import registry from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry, ColorTheme from .api import WebhookView @@ -477,15 +478,36 @@ class PluginSettingsApiTest(InvenTreeAPITestCase): self.get(url, expected_code=200) - def test_invalid_plugin_slug(self): - """Test that an invalid plugin slug returns a 404""" + def test_valid_plugin_slug(self): + """Test that an valid plugin slug runs through""" + # load plugin configs + fixtures = PluginConfig.objects.all() + if not fixtures: + registry.reload_plugins() + fixtures = PluginConfig.objects.all() + # get data + url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'}) + response = self.get(url, expected_code=200) + + # check the right setting came through + self.assertTrue(response.data['key'], 'API_KEY') + self.assertTrue(response.data['plugin'], 'sample') + self.assertTrue(response.data['type'], 'string') + self.assertTrue(response.data['description'], 'Key required for accessing external API') + + # Failure mode tests + + # Non - exsistant plugin url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'}) - response = self.get(url, expected_code=404) - self.assertIn("Plugin 'doesnotexist' not installed", str(response.data)) + # Wrong key + url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'doesnotexsist'}) + response = self.get(url, expected_code=404) + self.assertIn("Plugin 'sample' has no setting matching 'doesnotexsist'", str(response.data)) + def test_invalid_setting_key(self): """Test that an invalid setting key returns a 404""" ... diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index c7a824d8c8..cb4b939157 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -156,7 +156,7 @@ class LabelPrintMixin: # Offload a background task to print the provided label offload_task( - 'plugin.events.print_label', + 'plugin.base.label.label.print_label', plugin.plugin_slug(), image, label_instance=label_instance, diff --git a/InvenTree/label/tests.py b/InvenTree/label/tests.py index 53724a36fc..ad1aaba9c8 100644 --- a/InvenTree/label/tests.py +++ b/InvenTree/label/tests.py @@ -7,13 +7,12 @@ import os from django.conf import settings from django.apps import apps -from django.urls import reverse from django.core.exceptions import ValidationError from InvenTree.helpers import validateFilterString from InvenTree.api_tester import InvenTreeAPITestCase -from .models import StockItemLabel, StockLocationLabel, PartLabel +from .models import StockItemLabel, StockLocationLabel from stock.models import StockItem @@ -86,13 +85,3 @@ class LabelTest(InvenTreeAPITestCase): with self.assertRaises(ValidationError): validateFilterString(bad_filter_string, model=StockItem) - - def test_label_rendering(self): - """Test label rendering""" - - labels = PartLabel.objects.all() - part = PartLabel.objects.first() - - for label in labels: - url = reverse('api-part-label-print', kwargs={'pk': label.pk}) - self.get(f'{url}?parts={part.pk}', expected_code=200) diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py index b8e40e4271..ae38ae6e5b 100644 --- a/InvenTree/plugin/__init__.py +++ b/InvenTree/plugin/__init__.py @@ -3,17 +3,14 @@ Utility file to enable simper imports """ from .registry import registry -from .plugin import InvenTreePluginBase -from .integration import IntegrationPluginBase -from .action import ActionPlugin - +from .plugin import InvenTreePlugin, IntegrationPluginBase from .helpers import MixinNotImplementedError, MixinImplementationError __all__ = [ - 'ActionPlugin', - 'IntegrationPluginBase', - 'InvenTreePluginBase', 'registry', + + 'InvenTreePlugin', + IntegrationPluginBase, 'MixinNotImplementedError', 'MixinImplementationError', ] diff --git a/InvenTree/plugin/action.py b/InvenTree/plugin/action.py deleted file mode 100644 index 9586355aea..0000000000 --- a/InvenTree/plugin/action.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -"""Class for ActionPlugin""" - -import logging -import warnings - -from plugin.builtin.action.mixins import ActionMixin -import plugin.integration - - -logger = logging.getLogger("inventree") - - -class ActionPlugin(ActionMixin, plugin.integration.IntegrationPluginBase): - """ - Legacy action definition - will be replaced - Please use the new Integration Plugin API and the Action mixin - """ - # TODO @matmair remove this with InvenTree 0.7.0 - def __init__(self, user=None, data=None): - warnings.warn("using the ActionPlugin is depreceated", DeprecationWarning) - super().__init__() - self.init(user, data) diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index b9fd6e643d..f3710e4835 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -5,6 +5,7 @@ JSON API for the plugin app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.conf import settings from django.urls import include, re_path from rest_framework import generics @@ -16,6 +17,8 @@ from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from common.api import GlobalSettingsPermissions +from plugin.base.barcodes.api import barcode_api_urls +from plugin.base.action.api import ActionPluginView from plugin.models import PluginConfig, PluginSetting import plugin.serializers as PluginSerializers from plugin.registry import registry @@ -141,7 +144,8 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView): plugin = registry.get_plugin(plugin_slug) if plugin is None: - raise NotFound(detail=f"Plugin '{plugin_slug}' not found") + # This only occurs if the plugin mechanism broke + raise NotFound(detail=f"Plugin '{plugin_slug}' not found") # pragma: no cover settings = getattr(plugin, 'SETTINGS', {}) @@ -157,6 +161,11 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView): plugin_api_urls = [ + re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), + re_path(r'^barcode/', include(barcode_api_urls)), +] + +general_plugin_api_urls = [ # Plugin settings URLs re_path(r'^settings/', include([ @@ -174,3 +183,8 @@ plugin_api_urls = [ # Anything else re_path(r'^.*$', PluginList.as_view(), name='api-plugin-list'), ] + +if settings.PLUGINS_ENABLED: + plugin_api_urls.append( + re_path(r'^plugin/', include(general_plugin_api_urls)) + ) diff --git a/InvenTree/plugin/base/__init__.py b/InvenTree/plugin/base/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/base/action/__init__.py b/InvenTree/plugin/base/action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/base/action/api.py b/InvenTree/plugin/base/action/api.py new file mode 100644 index 0000000000..998e410dce --- /dev/null +++ b/InvenTree/plugin/base/action/api.py @@ -0,0 +1,45 @@ +"""APIs for action plugins""" +from django.utils.translation import gettext_lazy as _ + +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.views import APIView + +from plugin import registry + + +class ActionPluginView(APIView): + """ + Endpoint for running custom action plugins. + """ + + permission_classes = [ + permissions.IsAuthenticated, + ] + + def post(self, request, *args, **kwargs): + + action = request.data.get('action', None) + + data = request.data.get('data', None) + + if action is None: + return Response({ + 'error': _("No action specified") + }) + + action_plugins = registry.with_mixin('action') + for plugin in action_plugins: + if plugin.action_name() == action: + # TODO @matmair use easier syntax once InvenTree 0.7.0 is released + plugin.init(request.user, data=data) + + plugin.perform_action() + + return Response(plugin.get_response()) + + # If we got to here, no matching action was found + return Response({ + 'error': _("No matching action found"), + "action": action, + }) diff --git a/InvenTree/plugin/builtin/action/mixins.py b/InvenTree/plugin/base/action/mixins.py similarity index 93% rename from InvenTree/plugin/builtin/action/mixins.py rename to InvenTree/plugin/base/action/mixins.py index 18a1876659..70fea86a7e 100644 --- a/InvenTree/plugin/builtin/action/mixins.py +++ b/InvenTree/plugin/base/action/mixins.py @@ -15,16 +15,17 @@ class ActionMixin: """ MIXIN_NAME = 'Actions' - def __init__(self): + def __init__(self, user=None, data=None): super().__init__() self.add_mixin('action', True, __class__) + self.init(user, data) def action_name(self): """ Action name for this plugin. If the ACTION_NAME parameter is empty, - uses the PLUGIN_NAME instead. + uses the NAME instead. """ if self.ACTION_NAME: return self.ACTION_NAME diff --git a/InvenTree/plugin/test_action.py b/InvenTree/plugin/base/action/test_action.py similarity index 50% rename from InvenTree/plugin/test_action.py rename to InvenTree/plugin/base/action/test_action.py index 2a9e4a9a37..9de672cd6f 100644 --- a/InvenTree/plugin/test_action.py +++ b/InvenTree/plugin/base/action/test_action.py @@ -1,34 +1,38 @@ """ Unit tests for action plugins """ from django.test import TestCase +from django.contrib.auth import get_user_model -from plugin.action import ActionPlugin +from plugin import InvenTreePlugin +from plugin.mixins import ActionMixin -class ActionPluginTests(TestCase): - """ Tests for ActionPlugin """ +class ActionMixinTests(TestCase): + """ Tests for ActionMixin """ ACTION_RETURN = 'a action was performed' def setUp(self): - self.plugin = ActionPlugin('user') + class SimplePlugin(ActionMixin, InvenTreePlugin): + pass + self.plugin = SimplePlugin('user') - class TestActionPlugin(ActionPlugin): + class TestActionPlugin(ActionMixin, InvenTreePlugin): """a action plugin""" ACTION_NAME = 'abc123' def perform_action(self): - return ActionPluginTests.ACTION_RETURN + 'action' + return ActionMixinTests.ACTION_RETURN + 'action' def get_result(self): - return ActionPluginTests.ACTION_RETURN + 'result' + return ActionMixinTests.ACTION_RETURN + 'result' def get_info(self): - return ActionPluginTests.ACTION_RETURN + 'info' + return ActionMixinTests.ACTION_RETURN + 'info' self.action_plugin = TestActionPlugin('user') - class NameActionPlugin(ActionPlugin): - PLUGIN_NAME = 'Aplugin' + class NameActionPlugin(ActionMixin, InvenTreePlugin): + NAME = 'Aplugin' self.action_name = NameActionPlugin('user') @@ -59,3 +63,32 @@ class ActionPluginTests(TestCase): "result": self.ACTION_RETURN + 'result', "info": self.ACTION_RETURN + 'info', }) + + +class APITests(TestCase): + """ Tests for action api """ + + def setUp(self): + # Create a user for auth + user = get_user_model() + self.test_user = user.objects.create_user('testuser', 'test@testing.com', 'password') + self.client.login(username='testuser', password='password') + + def test_post_errors(self): + """Check the possible errors with post""" + + # Test empty request + response = self.client.post('/api/action/') + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {'error': 'No action specified'} + ) + + # Test non-exsisting action + response = self.client.post('/api/action/', data={'action': "nonexsisting"}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {'error': 'No matching action found', 'action': 'nonexsisting'} + ) diff --git a/InvenTree/plugin/base/barcodes/__init__.py b/InvenTree/plugin/base/barcodes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/barcode.py b/InvenTree/plugin/base/barcodes/api.py similarity index 98% rename from InvenTree/plugin/barcode.py rename to InvenTree/plugin/base/barcodes/api.py index 98b111f073..296986b2d1 100644 --- a/InvenTree/plugin/barcode.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -11,7 +11,7 @@ from stock.models import StockItem from stock.serializers import StockItemSerializer from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin -from plugin.builtin.barcodes.mixins import hash_barcode +from plugin.base.barcodes.mixins import hash_barcode from plugin import registry @@ -237,7 +237,7 @@ class BarcodeAssign(APIView): barcode_api_urls = [ - + # Link a barcode to a part path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), # Catch-all performs barcode 'scan' diff --git a/InvenTree/plugin/builtin/barcodes/mixins.py b/InvenTree/plugin/base/barcodes/mixins.py similarity index 100% rename from InvenTree/plugin/builtin/barcodes/mixins.py rename to InvenTree/plugin/base/barcodes/mixins.py diff --git a/InvenTree/plugin/test_barcode.py b/InvenTree/plugin/base/barcodes/test_barcode.py similarity index 100% rename from InvenTree/plugin/test_barcode.py rename to InvenTree/plugin/base/barcodes/test_barcode.py diff --git a/InvenTree/plugin/base/event/__init__.py b/InvenTree/plugin/base/event/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py new file mode 100644 index 0000000000..59dfdce86a --- /dev/null +++ b/InvenTree/plugin/base/event/events.py @@ -0,0 +1,192 @@ +""" +Functions for triggering and responding to server side events +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging + +from django.conf import settings +from django.db import transaction +from django.db.models.signals import post_save, post_delete +from django.dispatch.dispatcher import receiver + +from InvenTree.ready import canAppAccessDatabase, isImportingData +from InvenTree.tasks import offload_task + +from plugin.registry import registry + + +logger = logging.getLogger('inventree') + + +def trigger_event(event, *args, **kwargs): + """ + Trigger an event with optional arguments. + + This event will be stored in the database, + and the worker will respond to it later on. + """ + + if not settings.PLUGINS_ENABLED: + # Do nothing if plugins are not enabled + return + + if not canAppAccessDatabase(): + logger.debug(f"Ignoring triggered event '{event}' - database not ready") + return + + logger.debug(f"Event triggered: '{event}'") + + offload_task( + 'plugin.events.register_event', + event, + *args, + **kwargs + ) + + +def register_event(event, *args, **kwargs): + """ + Register the event with any interested plugins. + + Note: This function is processed by the background worker, + as it performs multiple database access operations. + """ + from common.models import InvenTreeSetting + + logger.debug(f"Registering triggered event: '{event}'") + + # Determine if there are any plugins which are interested in responding + if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): + + with transaction.atomic(): + + for slug, plugin in registry.plugins.items(): + + if plugin.mixin_enabled('events'): + + config = plugin.plugin_config() + + if config and config.active: + + logger.debug(f"Registering callback for plugin '{slug}'") + + # Offload a separate task for each plugin + offload_task( + 'plugin.events.process_event', + slug, + event, + *args, + **kwargs + ) + + +def process_event(plugin_slug, 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"Plugin '{plugin_slug}' is processing triggered event '{event}'") + + plugin = registry.plugins.get(plugin_slug, None) + + if plugin is None: + logger.error(f"Could not find matching plugin for '{plugin_slug}'") + return + + plugin.process_event(event, *args, **kwargs) + + +def allow_table_event(table_name): + """ + Determine if an automatic event should be fired for a given table. + We *do not* want events to be fired for some tables! + """ + + if isImportingData(): + # Prevent table events during the data import process + return False + + table_name = table_name.lower().strip() + + # Ignore any tables which start with these prefixes + ignore_prefixes = [ + 'account_', + 'auth_', + 'authtoken_', + 'django_', + 'error_', + 'exchange_', + 'otp_', + 'plugin_', + 'socialaccount_', + 'user_', + 'users_', + ] + + if any([table_name.startswith(prefix) for prefix in ignore_prefixes]): + return False + + ignore_tables = [ + 'common_notificationentry', + 'common_webhookendpoint', + 'common_webhookmessage', + ] + + if table_name in ignore_tables: + return False + + return True + + +@receiver(post_save) +def after_save(sender, instance, created, **kwargs): + """ + Trigger an event whenever a database entry is saved + """ + + 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 + + if created: + trigger_event( + f'{table}.created', + id=instance.id, + model=sender.__name__, + ) + else: + trigger_event( + f'{table}.saved', + id=instance.id, + model=sender.__name__, + ) + + +@receiver(post_delete) +def after_delete(sender, instance, **kwargs): + """ + Trigger an event whenever a database entry is deleted + """ + + table = sender.objects.model._meta.db_table + + if not allow_table_event(table): + return + + trigger_event( + f'{table}.deleted', + model=sender.__name__, + ) diff --git a/InvenTree/plugin/base/event/mixins.py b/InvenTree/plugin/base/event/mixins.py new file mode 100644 index 0000000000..8df9bfd164 --- /dev/null +++ b/InvenTree/plugin/base/event/mixins.py @@ -0,0 +1,29 @@ +"""Plugin mixin class for events""" + +from plugin.helpers import MixinNotImplementedError + + +class EventMixin: + """ + Mixin that provides support for responding to triggered events. + + Implementing classes must provide a "process_event" function: + """ + + def process_event(self, event, *args, **kwargs): + """ + Function to handle events + Must be overridden by plugin + """ + # Default implementation does not do anything + raise MixinNotImplementedError + + class MixinMeta: + """ + Meta options for this mixin + """ + MIXIN_NAME = 'Events' + + def __init__(self): + super().__init__() + self.add_mixin('events', True, __class__) diff --git a/InvenTree/plugin/base/integration/__init__.py b/InvenTree/plugin/base/integration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py similarity index 90% rename from InvenTree/plugin/builtin/integration/mixins.py rename to InvenTree/plugin/base/integration/mixins.py index b22efc9415..86e3092e4f 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -11,9 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError import InvenTree.helpers -from plugin.helpers import MixinImplementationError, MixinNotImplementedError +from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template from plugin.models import PluginConfig, PluginSetting -from plugin.template import render_template from plugin.urls import PLUGIN_BASE @@ -238,32 +237,6 @@ 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 "process_event" function: - """ - - def process_event(self, event, *args, **kwargs): - """ - Function to handle events - Must be overridden by plugin - """ - # Default implementation does not do anything - raise MixinNotImplementedError - - class MixinMeta: - """ - Meta options for this mixin - """ - MIXIN_NAME = 'Events' - - def __init__(self): - super().__init__() - self.add_mixin('events', True, __class__) - - class UrlsMixin: """ Mixin that enables custom URLs for the plugin @@ -396,42 +369,6 @@ class AppMixin: return True -class LabelPrintingMixin: - """ - Mixin which enables direct printing of stock labels. - - Each plugin must provide a PLUGIN_NAME attribute, which is used to uniquely identify the printer. - - The plugin must also implement the print_label() function - """ - - class MixinMeta: - """ - Meta options for this mixin - """ - MIXIN_NAME = 'Label printing' - - def __init__(self): # pragma: no cover - super().__init__() - self.add_mixin('labels', True, __class__) - - def print_label(self, label, **kwargs): - """ - Callback to print a single label - - Arguments: - label: A black-and-white pillow Image object - - kwargs: - length: The length of the label (in mm) - width: The width of the label (in mm) - - """ - - # Unimplemented (to be implemented by the particular plugin class) - ... # pragma: no cover - - class APICallMixin: """ Mixin that enables easier API calls for a plugin @@ -447,15 +384,15 @@ class APICallMixin: Example: ``` - from plugin import IntegrationPluginBase + from plugin import InvenTreePlugin from plugin.mixins import APICallMixin, SettingsMixin - class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase): + class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin): ''' A small api call sample ''' - PLUGIN_NAME = "Sample API Caller" + NAME = "Sample API Caller" SETTINGS = { 'API_TOKEN': { @@ -496,9 +433,9 @@ class APICallMixin: def has_api_call(self): """Is the mixin ready to call external APIs?""" if not bool(self.API_URL_SETTING): - raise ValueError("API_URL_SETTING must be defined") + raise MixinNotImplementedError("API_URL_SETTING must be defined") if not bool(self.API_TOKEN_SETTING): - raise ValueError("API_TOKEN_SETTING must be defined") + raise MixinNotImplementedError("API_TOKEN_SETTING must be defined") return True @property diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/base/integration/test_mixins.py similarity index 61% rename from InvenTree/plugin/test_integration.py rename to InvenTree/plugin/base/integration/test_mixins.py index 5b211e2d96..ef3f7062e3 100644 --- a/InvenTree/plugin/test_integration.py +++ b/InvenTree/plugin/base/integration/test_mixins.py @@ -1,17 +1,14 @@ -""" Unit tests for integration plugins """ +""" Unit tests for base mixins for plugins """ from django.test import TestCase from django.conf import settings from django.urls import include, re_path from django.contrib.auth import get_user_model -from datetime import datetime - -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin from plugin.urls import PLUGIN_BASE - -from plugin.samples.integration.sample import SampleIntegrationPlugin +from plugin.helpers import MixinNotImplementedError class BaseMixinDefinition: @@ -30,11 +27,11 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase): TEST_SETTINGS = {'SETTING1': {'default': '123', }} def setUp(self): - class SettingsCls(SettingsMixin, IntegrationPluginBase): + class SettingsCls(SettingsMixin, InvenTreePlugin): SETTINGS = self.TEST_SETTINGS self.mixin = SettingsCls() - class NoSettingsCls(SettingsMixin, IntegrationPluginBase): + class NoSettingsCls(SettingsMixin, InvenTreePlugin): pass self.mixin_nothing = NoSettingsCls() @@ -65,13 +62,13 @@ class UrlsMixinTest(BaseMixinDefinition, TestCase): MIXIN_ENABLE_CHECK = 'has_urls' def setUp(self): - class UrlsCls(UrlsMixin, IntegrationPluginBase): + class UrlsCls(UrlsMixin, InvenTreePlugin): def test(): return 'ccc' URLS = [re_path('testpath', test, name='test'), ] self.mixin = UrlsCls() - class NoUrlsCls(UrlsMixin, IntegrationPluginBase): + class NoUrlsCls(UrlsMixin, InvenTreePlugin): pass self.mixin_nothing = NoUrlsCls() @@ -104,7 +101,7 @@ class AppMixinTest(BaseMixinDefinition, TestCase): MIXIN_ENABLE_CHECK = 'has_app' def setUp(self): - class TestCls(AppMixin, IntegrationPluginBase): + class TestCls(AppMixin, InvenTreePlugin): pass self.mixin = TestCls() @@ -119,30 +116,32 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase): MIXIN_ENABLE_CHECK = 'has_naviation' def setUp(self): - class NavigationCls(NavigationMixin, IntegrationPluginBase): + class NavigationCls(NavigationMixin, InvenTreePlugin): NAVIGATION = [ {'name': 'aa', 'link': 'plugin:test:test_view'}, ] NAVIGATION_TAB_NAME = 'abcd1' self.mixin = NavigationCls() - class NothingNavigationCls(NavigationMixin, IntegrationPluginBase): + class NothingNavigationCls(NavigationMixin, InvenTreePlugin): pass self.nothing_mixin = NothingNavigationCls() def test_function(self): # check right configuration self.assertEqual(self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}, ]) - # check wrong links fails - with self.assertRaises(NotImplementedError): - class NavigationCls(NavigationMixin, IntegrationPluginBase): - NAVIGATION = ['aa', 'aa'] - NavigationCls() # navigation name self.assertEqual(self.mixin.navigation_name, 'abcd1') self.assertEqual(self.nothing_mixin.navigation_name, '') + def test_fail(self): + # check wrong links fails + with self.assertRaises(NotImplementedError): + class NavigationCls(NavigationMixin, InvenTreePlugin): + NAVIGATION = ['aa', 'aa'] + NavigationCls() + class APICallMixinTest(BaseMixinDefinition, TestCase): MIXIN_HUMAN_NAME = 'API calls' @@ -150,8 +149,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): MIXIN_ENABLE_CHECK = 'has_api_call' def setUp(self): - class MixinCls(APICallMixin, SettingsMixin, IntegrationPluginBase): - PLUGIN_NAME = "Sample API Caller" + class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin): + NAME = "Sample API Caller" SETTINGS = { 'API_TOKEN': { @@ -167,22 +166,23 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): API_URL_SETTING = 'API_URL' API_TOKEN_SETTING = 'API_TOKEN' - def get_external_url(self): + def get_external_url(self, simple: bool = True): ''' returns data from the sample endpoint ''' - return self.api_call('api/users/2') + return self.api_call('api/users/2', simple_response=simple) self.mixin = MixinCls() - class WrongCLS(APICallMixin, IntegrationPluginBase): + class WrongCLS(APICallMixin, InvenTreePlugin): pass self.mixin_wrong = WrongCLS() - class WrongCLS2(APICallMixin, IntegrationPluginBase): + class WrongCLS2(APICallMixin, InvenTreePlugin): API_URL_SETTING = 'test' self.mixin_wrong2 = WrongCLS2() - def test_function(self): + def test_base_setup(self): + """Test that the base settings work""" # check init self.assertTrue(self.mixin.has_api_call) # api_url @@ -192,6 +192,8 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): headers = self.mixin.api_headers self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'}) + def test_args(self): + """Test that building up args work""" # api_build_url_args # 1 arg result = self.mixin.api_build_url_args({'a': 'b'}) @@ -203,88 +205,42 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f', ]}) self.assertEqual(result, '?a=b&c=d,e,f') + def test_api_call(self): + """Test that api calls work""" # api_call result = self.mixin.get_external_url() self.assertTrue(result) self.assertIn('data', result,) + # api_call without json conversion + result = self.mixin.get_external_url(False) + self.assertTrue(result) + self.assertEqual(result.reason, 'OK') + + # api_call with full url + result = self.mixin.api_call('https://reqres.in/api/users/2', endpoint_is_url=True) + self.assertTrue(result) + + # api_call with post and data + result = self.mixin.api_call( + 'api/users/', + data={"name": "morpheus", "job": "leader"}, + method='POST' + ) + self.assertTrue(result) + self.assertEqual(result['name'], 'morpheus') + + # api_call with filter + result = self.mixin.api_call('api/users', url_args={'page': '2'}) + self.assertTrue(result) + self.assertEqual(result['page'], 2) + + def test_function_errors(self): + """Test function errors""" # wrongly defined plugins should not load - with self.assertRaises(ValueError): + with self.assertRaises(MixinNotImplementedError): self.mixin_wrong.has_api_call() # cover wrong token setting - with self.assertRaises(ValueError): - self.mixin_wrong.has_api_call() - - -class IntegrationPluginBaseTests(TestCase): - """ Tests for IntegrationPluginBase """ - - def setUp(self): - self.plugin = IntegrationPluginBase() - - class SimpeIntegrationPluginBase(IntegrationPluginBase): - PLUGIN_NAME = 'SimplePlugin' - - self.plugin_simple = SimpeIntegrationPluginBase() - - class NameIntegrationPluginBase(IntegrationPluginBase): - PLUGIN_NAME = 'Aplugin' - PLUGIN_SLUG = 'a' - PLUGIN_TITLE = 'a titel' - PUBLISH_DATE = "1111-11-11" - AUTHOR = 'AA BB' - DESCRIPTION = 'A description' - VERSION = '1.2.3a' - WEBSITE = 'http://aa.bb/cc' - LICENSE = 'MIT' - - self.plugin_name = NameIntegrationPluginBase() - self.plugin_sample = SampleIntegrationPlugin() - - def test_action_name(self): - """check the name definition possibilities""" - # plugin_name - self.assertEqual(self.plugin.plugin_name(), '') - self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin') - self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin') - - # is_sampe - self.assertEqual(self.plugin.is_sample, False) - self.assertEqual(self.plugin_sample.is_sample, True) - - # slug - self.assertEqual(self.plugin.slug, '') - self.assertEqual(self.plugin_simple.slug, 'simpleplugin') - self.assertEqual(self.plugin_name.slug, 'a') - - # human_name - self.assertEqual(self.plugin.human_name, '') - self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin') - self.assertEqual(self.plugin_name.human_name, 'a titel') - - # description - self.assertEqual(self.plugin.description, '') - self.assertEqual(self.plugin_simple.description, 'SimplePlugin') - self.assertEqual(self.plugin_name.description, 'A description') - - # author - self.assertEqual(self.plugin_name.author, 'AA BB') - - # pub_date - self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0)) - - # version - self.assertEqual(self.plugin.version, None) - self.assertEqual(self.plugin_simple.version, None) - self.assertEqual(self.plugin_name.version, '1.2.3a') - - # website - self.assertEqual(self.plugin.website, None) - self.assertEqual(self.plugin_simple.website, None) - self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc') - - # license - self.assertEqual(self.plugin.license, None) - self.assertEqual(self.plugin_simple.license, None) - self.assertEqual(self.plugin_name.license, 'MIT') + with self.assertRaises(MixinNotImplementedError): + self.mixin_wrong2.has_api_call() diff --git a/InvenTree/plugin/base/label/__init__.py b/InvenTree/plugin/base/label/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/base/label/label.py b/InvenTree/plugin/base/label/label.py new file mode 100644 index 0000000000..7f29ba70f4 --- /dev/null +++ b/InvenTree/plugin/base/label/label.py @@ -0,0 +1,53 @@ +"""Functions to print a label to a mixin printer""" +import logging + +from django.utils.translation import gettext_lazy as _ + +from plugin.registry import registry +import common.notifications + + +logger = logging.getLogger('inventree') + + +def print_label(plugin_slug, label_image, label_instance=None, user=None): + """ + Print label with the provided plugin. + + This task is nominally handled by the background worker. + + If the printing fails (throws an exception) then the user is notified. + + Arguments: + plugin_slug: The unique slug (key) of the plugin + label_image: A PIL.Image image object to be printed + """ + + logger.info(f"Plugin '{plugin_slug}' is printing a label") + + plugin = registry.plugins.get(plugin_slug, None) + + if plugin is None: + logger.error(f"Could not find matching plugin for '{plugin_slug}'") + return + + try: + plugin.print_label(label_image, width=label_instance.width, height=label_instance.height) + except Exception as e: + # Plugin threw an error - notify the user who attempted to print + + ctx = { + 'name': _('Label printing failed'), + 'message': str(e), + } + + logger.error(f"Label printing failed: Sending notification to user '{user}'") + + # Throw an error against the plugin instance + common.notifications.trigger_notifaction( + plugin.plugin_config(), + 'label.printing_failed', + targets=[user], + context=ctx, + delivery_methods=[common.notifications.UIMessageNotification] + ) diff --git a/InvenTree/plugin/base/label/mixins.py b/InvenTree/plugin/base/label/mixins.py new file mode 100644 index 0000000000..b6c4d53f46 --- /dev/null +++ b/InvenTree/plugin/base/label/mixins.py @@ -0,0 +1,39 @@ +"""Plugin mixin classes for label plugins""" + +from plugin.helpers import MixinNotImplementedError + + +class LabelPrintingMixin: + """ + Mixin which enables direct printing of stock labels. + + Each plugin must provide a NAME attribute, which is used to uniquely identify the printer. + + The plugin must also implement the print_label() function + """ + + class MixinMeta: + """ + Meta options for this mixin + """ + MIXIN_NAME = 'Label printing' + + def __init__(self): # pragma: no cover + super().__init__() + self.add_mixin('labels', True, __class__) + + def print_label(self, label, **kwargs): + """ + Callback to print a single label + + Arguments: + label: A black-and-white pillow Image object + + kwargs: + length: The length of the label (in mm) + width: The width of the label (in mm) + + """ + + # Unimplemented (to be implemented by the particular plugin class) + MixinNotImplementedError('This Plugin must implement a `print_label` method') diff --git a/InvenTree/plugin/builtin/action/simpleactionplugin.py b/InvenTree/plugin/builtin/action/simpleactionplugin.py index 01a0829887..d2a321789d 100644 --- a/InvenTree/plugin/builtin/action/simpleactionplugin.py +++ b/InvenTree/plugin/builtin/action/simpleactionplugin.py @@ -1,15 +1,16 @@ # -*- coding: utf-8 -*- -"""sample implementation for ActionPlugin""" -from plugin.action import ActionPlugin +"""sample implementation for ActionMixin""" +from plugin import InvenTreePlugin +from plugin.mixins import ActionMixin -class SimpleActionPlugin(ActionPlugin): +class SimpleActionPlugin(ActionMixin, InvenTreePlugin): """ An EXTREMELY simple action plugin which demonstrates - the capability of the ActionPlugin class + the capability of the ActionMixin class """ - PLUGIN_NAME = "SimpleActionPlugin" + NAME = "SimpleActionPlugin" ACTION_NAME = "simple" def perform_action(self): diff --git a/InvenTree/plugin/builtin/action/test_samples_action.py b/InvenTree/plugin/builtin/action/test_simpleactionplugin.py similarity index 100% rename from InvenTree/plugin/builtin/action/test_samples_action.py rename to InvenTree/plugin/builtin/action/test_simpleactionplugin.py diff --git a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py index 939bf12c08..f9f498ccd8 100644 --- a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py +++ b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py @@ -13,7 +13,7 @@ references model objects actually exist in the database. import json -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import BarcodeMixin from stock.models import StockItem, StockLocation @@ -22,9 +22,9 @@ from part.models import Part from rest_framework.exceptions import ValidationError -class InvenTreeBarcodePlugin(BarcodeMixin, IntegrationPluginBase): +class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): - PLUGIN_NAME = "InvenTreeBarcode" + NAME = "InvenTreeBarcode" def validate(self): """ diff --git a/InvenTree/plugin/builtin/integration/core_notifications.py b/InvenTree/plugin/builtin/integration/core_notifications.py index a26c69e877..67b61eea3b 100644 --- a/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/InvenTree/plugin/builtin/integration/core_notifications.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from allauth.account.models import EmailAddress -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import BulkNotificationMethod, SettingsMixin import InvenTree.tasks @@ -15,12 +15,12 @@ class PlgMixin: return CoreNotificationsPlugin -class CoreNotificationsPlugin(SettingsMixin, IntegrationPluginBase): +class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin): """ Core notification methods for InvenTree """ - PLUGIN_NAME = "CoreNotificationsPlugin" + NAME = "CoreNotificationsPlugin" AUTHOR = _('InvenTree contributors') DESCRIPTION = _('Integrated outgoing notificaton methods') diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index 043f3b97a3..193a1805fe 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -1,239 +1,9 @@ """ -Functions for triggering and responding to server side events +Import helper for events """ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +from plugin.base.event.events import trigger_event -import logging - -from django.utils.translation import gettext_lazy as _ - -from django.conf import settings -from django.db import transaction -from django.db.models.signals import post_save, post_delete -from django.dispatch.dispatcher import receiver - -from common.models import InvenTreeSetting -import common.notifications - -from InvenTree.ready import canAppAccessDatabase, isImportingData -from InvenTree.tasks import offload_task - -from plugin.registry import registry - - -logger = logging.getLogger('inventree') - - -def trigger_event(event, *args, **kwargs): - """ - Trigger an event with optional arguments. - - This event will be stored in the database, - and the worker will respond to it later on. - """ - - if not settings.PLUGINS_ENABLED: - # Do nothing if plugins are not enabled - return - - if not canAppAccessDatabase(): - logger.debug(f"Ignoring triggered event '{event}' - database not ready") - return - - logger.debug(f"Event triggered: '{event}'") - - offload_task( - 'plugin.events.register_event', - event, - *args, - **kwargs - ) - - -def register_event(event, *args, **kwargs): - """ - Register the event with any interested plugins. - - Note: This function is processed by the background worker, - as it performs multiple database access operations. - """ - - logger.debug(f"Registering triggered event: '{event}'") - - # Determine if there are any plugins which are interested in responding - if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_EVENTS'): - - with transaction.atomic(): - - for slug, plugin in registry.plugins.items(): - - if plugin.mixin_enabled('events'): - - config = plugin.plugin_config() - - if config and config.active: - - logger.debug(f"Registering callback for plugin '{slug}'") - - # Offload a separate task for each plugin - offload_task( - 'plugin.events.process_event', - slug, - event, - *args, - **kwargs - ) - - -def process_event(plugin_slug, 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"Plugin '{plugin_slug}' is processing triggered event '{event}'") - - plugin = registry.plugins.get(plugin_slug, None) - - if plugin is None: - logger.error(f"Could not find matching plugin for '{plugin_slug}'") - return - - plugin.process_event(event, *args, **kwargs) - - -def allow_table_event(table_name): - """ - Determine if an automatic event should be fired for a given table. - We *do not* want events to be fired for some tables! - """ - - if isImportingData(): - # Prevent table events during the data import process - return False - - table_name = table_name.lower().strip() - - # Ignore any tables which start with these prefixes - ignore_prefixes = [ - 'account_', - 'auth_', - 'authtoken_', - 'django_', - 'error_', - 'exchange_', - 'otp_', - 'plugin_', - 'socialaccount_', - 'user_', - 'users_', - ] - - if any([table_name.startswith(prefix) for prefix in ignore_prefixes]): - return False - - ignore_tables = [ - 'common_notificationentry', - 'common_webhookendpoint', - 'common_webhookmessage', - ] - - if table_name in ignore_tables: - return False - - return True - - -@receiver(post_save) -def after_save(sender, instance, created, **kwargs): - """ - Trigger an event whenever a database entry is saved - """ - - 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 - - if created: - trigger_event( - f'{table}.created', - id=instance.id, - model=sender.__name__, - ) - else: - trigger_event( - f'{table}.saved', - id=instance.id, - model=sender.__name__, - ) - - -@receiver(post_delete) -def after_delete(sender, instance, **kwargs): - """ - Trigger an event whenever a database entry is deleted - """ - - table = sender.objects.model._meta.db_table - - if not allow_table_event(table): - return - - trigger_event( - f'{table}.deleted', - model=sender.__name__, - ) - - -def print_label(plugin_slug, label_image, label_instance=None, user=None): - """ - Print label with the provided plugin. - - This task is nominally handled by the background worker. - - If the printing fails (throws an exception) then the user is notified. - - Arguments: - plugin_slug: The unique slug (key) of the plugin - label_image: A PIL.Image image object to be printed - """ - - logger.info(f"Plugin '{plugin_slug}' is printing a label") - - plugin = registry.plugins.get(plugin_slug, None) - - if plugin is None: - logger.error(f"Could not find matching plugin for '{plugin_slug}'") - return - - try: - plugin.print_label(label_image, width=label_instance.width, height=label_instance.height) - except Exception as e: - # Plugin threw an error - notify the user who attempted to print - - ctx = { - 'name': _('Label printing failed'), - 'message': str(e), - } - - logger.error(f"Label printing failed: Sending notification to user '{user}'") - - # Throw an error against the plugin instance - common.notifications.trigger_notifaction( - plugin.plugin_config(), - 'label.printing_failed', - targets=[user], - context=ctx, - delivery_methods=[common.notifications.UIMessageNotification] - ) +__all__ = [ + trigger_event, +] diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index f1753b1b45..1217fa4d47 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -8,12 +8,17 @@ import sysconfig import traceback import inspect import pkgutil +import logging +from django import template from django.conf import settings from django.core.exceptions import AppRegistryNotReady from django.db.utils import IntegrityError +logger = logging.getLogger('inventree') + + # region logging / errors class IntegrationPluginError(Exception): """ @@ -200,7 +205,7 @@ def get_plugins(pkg, baseclass): Return a list of all modules under a given package. - Modules must be a subclass of the provided 'baseclass' - - Modules must have a non-empty PLUGIN_NAME parameter + - Modules must have a non-empty NAME parameter """ plugins = [] @@ -212,8 +217,32 @@ def get_plugins(pkg, baseclass): # Iterate through each class in the module for item in get_classes(mod): plugin = item[1] - if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME: + if issubclass(plugin, baseclass) and plugin.NAME: plugins.append(plugin) return plugins # endregion + + +# region templates +def render_template(plugin, template_file, context=None): + """ + Locate and render a template file, available in the global template context. + """ + + try: + tmp = template.loader.get_template(template_file) + except template.TemplateDoesNotExist: + logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'") + + return f""" +
+ Template file {template_file} does not exist. +
+ """ + + # Render with the provided context + html = tmp.render(context) + + return html +# endregion diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py deleted file mode 100644 index c622c0402c..0000000000 --- a/InvenTree/plugin/integration.py +++ /dev/null @@ -1,261 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Class for IntegrationPluginBase and Mixin Base -""" - -import logging -import os -import inspect -from datetime import datetime -import pathlib - -from django.urls.base import reverse -from django.conf import settings -from django.utils.translation import gettext_lazy as _ - -import plugin.plugin as plugin_base -from plugin.helpers import get_git_log, GitStatus - - -logger = logging.getLogger("inventree") - - -class MixinBase: - """ - Base set of mixin functions and mechanisms - """ - - 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 - """ - - 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 - """ - - # get human name - human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key - - # register - self._mixinreg[key] = { - 'key': key, - 'human_name': human_name, - } - - @property - def registered_mixins(self, with_base: bool = False): - """ - Get all registered mixins for the plugin - """ - - mixins = getattr(self, '_mixinreg', None) - if mixins: - # filter out base - if not with_base and 'base' in mixins: - del mixins['base'] - # only return dict - mixins = [a for a in mixins.values()] - return mixins - - -class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase): - """ - The IntegrationPluginBase class is used to integrate with 3rd party software - """ - - AUTHOR = None - DESCRIPTION = None - PUBLISH_DATE = None - VERSION = None - WEBSITE = None - LICENSE = None - - def __init__(self): - super().__init__() - self.add_mixin('base') - self.def_path = inspect.getfile(self.__class__) - self.path = os.path.dirname(self.def_path) - - self.define_package() - - @property - def _is_package(self): - """ - Is the plugin delivered as a package - """ - return getattr(self, 'is_package', False) - - @property - def is_sample(self): - """ - Is this plugin part of the samples? - """ - path = str(self.package_path) - return path.startswith('plugin/samples/') - - # region properties - @property - def slug(self): - """ - Slug of plugin - """ - return self.plugin_slug() - - @property - def name(self): - """ - Name of plugin - """ - return self.plugin_name() - - @property - def human_name(self): - """ - Human readable name of plugin - """ - return self.plugin_title() - - @property - def description(self): - """ - Description of plugin - """ - description = getattr(self, 'DESCRIPTION', None) - if not description: - description = self.plugin_name() - return description - - @property - def author(self): - """ - Author of plugin - either from plugin settings or git - """ - author = getattr(self, 'AUTHOR', None) - if not author: - author = self.package.get('author') - if not author: - author = _('No author found') # pragma: no cover - return author - - @property - def pub_date(self): - """ - Publishing date of plugin - either from plugin settings or git - """ - pub_date = getattr(self, 'PUBLISH_DATE', None) - if not pub_date: - pub_date = self.package.get('date') - else: - pub_date = datetime.fromisoformat(str(pub_date)) - if not pub_date: - pub_date = _('No date found') # pragma: no cover - return pub_date - - @property - def version(self): - """ - Version of plugin - """ - version = getattr(self, 'VERSION', None) - return version - - @property - def website(self): - """ - Website of plugin - if set else None - """ - website = getattr(self, 'WEBSITE', None) - return website - - @property - def license(self): - """ - License of plugin - """ - lic = getattr(self, 'LICENSE', None) - return lic - # endregion - - @property - def package_path(self): - """ - Path to the plugin - """ - if self._is_package: - return self.__module__ # pragma: no cover - return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR) - - @property - def settings_url(self): - """ - URL to the settings panel for this plugin - """ - return f'{reverse("settings")}#select-plugin-{self.slug}' - - # region mixins - def mixin(self, key): - """ - Check if mixin is registered - """ - return key in self._mixins - - def mixin_enabled(self, key): - """ - Check if mixin is registered, enabled and ready - """ - if self.mixin(key): - fnc_name = self._mixins.get(key) - - # Allow for simple case where the mixin is "always" ready - if fnc_name is True: - return True - - return getattr(self, fnc_name, True) - return False - # endregion - - # region package info - def _get_package_commit(self): - """ - Get last git commit for the plugin - """ - return get_git_log(self.def_path) - - def _get_package_metadata(self): - """ - Get package metadata for plugin - """ - return {} # pragma: no cover # TODO add usage for package metadata - - def define_package(self): - """ - Add package info of the plugin into plugins context - """ - package = self._get_package_metadata() if self._is_package else self._get_package_commit() - - # process date - if package.get('date'): - package['date'] = datetime.fromisoformat(package.get('date')) - - # process sign state - sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N) - if sign_state.status == 0: - self.sign_color = 'success' # pragma: no cover - elif sign_state.status == 1: - self.sign_color = 'warning' - else: - self.sign_color = 'danger' # pragma: no cover - - # set variables - self.package = package - self.sign_state = sign_state - # endregion diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 97d27b48fc..de8ad4bc03 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -2,12 +2,14 @@ Utility class to enable simpler imports """ -from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin, PanelMixin +from ..base.integration.mixins import APICallMixin, AppMixin, SettingsMixin, ScheduleMixin, UrlsMixin, NavigationMixin, PanelMixin from common.notifications import SingleNotificationMethod, BulkNotificationMethod -from ..builtin.action.mixins import ActionMixin -from ..builtin.barcodes.mixins import BarcodeMixin +from ..base.action.mixins import ActionMixin +from ..base.barcodes.mixins import BarcodeMixin +from ..base.event.mixins import EventMixin +from ..base.label.mixins import LabelPrintingMixin __all__ = [ 'APICallMixin', diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 18320cc34b..3d2d143eea 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -4,14 +4,16 @@ Plugin model definitions # -*- coding: utf-8 -*- from __future__ import unicode_literals +import warnings from django.utils.translation import gettext_lazy as _ from django.db import models from django.contrib.auth.models import User +from django.conf import settings import common.models -from plugin import InvenTreePluginBase, registry +from plugin import InvenTreePlugin, registry class PluginConfig(models.Model): @@ -59,7 +61,7 @@ class PluginConfig(models.Model): try: return self.plugin._mixinreg - except (AttributeError, ValueError): + except (AttributeError, ValueError): # pragma: no cover return {} # functions @@ -97,6 +99,8 @@ class PluginConfig(models.Model): if not reload: if (self.active is False and self.__org_active is True) or \ (self.active is True and self.__org_active is False): + if settings.PLUGIN_TESTING: + warnings.warn('A reload was triggered') registry.reload_plugins() return ret @@ -141,7 +145,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting): if plugin: - if issubclass(plugin.__class__, InvenTreePluginBase): + if issubclass(plugin.__class__, InvenTreePlugin): plugin = plugin.plugin_config() kwargs['settings'] = registry.mixins_settings.get(plugin.key, {}) diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index c6af7f20d8..b008865f5c 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -2,33 +2,71 @@ """ Base Class for InvenTree plugins """ +import logging +import os +import inspect +from datetime import datetime +import pathlib import warnings +from django.conf import settings from django.db.utils import OperationalError, ProgrammingError from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ +from django.urls.base import reverse + +from plugin.helpers import get_git_log, GitStatus -class InvenTreePluginBase(): - """ - Base class for a plugin - DO NOT USE THIS DIRECTLY, USE plugin.IntegrationPluginBase - """ +logger = logging.getLogger("inventree") - def __init__(self): - pass + +class MetaBase: + """Base class for a plugins metadata""" # Override the plugin name for each concrete plugin instance - PLUGIN_NAME = '' + NAME = '' + SLUG = None + TITLE = None - PLUGIN_SLUG = None + def get_meta_value(self, key: str, old_key: str = None, __default=None): + """Reference a meta item with a key - PLUGIN_TITLE = None + Args: + key (str): key for the value + old_key (str, optional): depreceated key - will throw warning + __default (optional): Value if nothing with key can be found. Defaults to None. + + Returns: + Value referenced with key, old_key or __default if set and not value found + """ + value = getattr(self, key, None) + + # The key was not used + if old_key and value is None: + value = getattr(self, old_key, None) + + # Sound of a warning if old_key worked + if value: + warnings.warn(f'Usage of {old_key} was depreciated in 0.7.0 in favour of {key}', DeprecationWarning) + + # Use __default if still nothing set + if (value is None) and __default: + return __default + return value def plugin_name(self): """ Name of plugin """ - return self.PLUGIN_NAME + return self.get_meta_value('NAME', 'PLUGIN_NAME') + + @property + def name(self): + """ + Name of plugin + """ + return self.plugin_name() def plugin_slug(self): """ @@ -36,22 +74,35 @@ class InvenTreePluginBase(): If not set plugin name slugified """ - slug = getattr(self, 'PLUGIN_SLUG', None) - - if slug is None: + slug = self.get_meta_value('SLUG', 'PLUGIN_SLUG', None) + if not slug: slug = self.plugin_name() return slugify(slug.lower()) + @property + def slug(self): + """ + Slug of plugin + """ + return self.plugin_slug() + def plugin_title(self): """ Title of plugin """ - if self.PLUGIN_TITLE: - return self.PLUGIN_TITLE - else: - return self.plugin_name() + title = self.get_meta_value('TITLE', 'PLUGIN_TITLE', None) + if title: + return title + return self.plugin_name() + + @property + def human_name(self): + """ + Human readable name of plugin + """ + return self.plugin_title() def plugin_config(self): """ @@ -83,11 +134,230 @@ class InvenTreePluginBase(): return False # pragma: no cover -# TODO @matmair remove after InvenTree 0.7.0 release -class InvenTreePlugin(InvenTreePluginBase): +class MixinBase: """ - This is here for leagcy reasons and will be removed in the next major release + Base set of mixin functions and mechanisms """ - def __init__(self): # pragma: no cover - warnings.warn("Using the InvenTreePlugin is depreceated", DeprecationWarning) + + def __init__(self, *args, **kwargs) -> None: + self._mixinreg = {} + self._mixins = {} + super().__init__(*args, **kwargs) + + def mixin(self, key): + """ + Check if mixin is registered + """ + return key in self._mixins + + def mixin_enabled(self, key): + """ + Check if mixin is registered, enabled and ready + """ + if self.mixin(key): + fnc_name = self._mixins.get(key) + + # Allow for simple case where the mixin is "always" ready + if fnc_name is True: + return True + + return getattr(self, fnc_name, True) + return False + + def add_mixin(self, key: str, fnc_enabled=True, cls=None): + """ + 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 + """ + + # get human name + human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key + + # register + self._mixinreg[key] = { + 'key': key, + 'human_name': human_name, + } + + @property + def registered_mixins(self, with_base: bool = False): + """ + Get all registered mixins for the plugin + """ + + mixins = getattr(self, '_mixinreg', None) + if mixins: + # filter out base + if not with_base and 'base' in mixins: + del mixins['base'] + # only return dict + mixins = [a for a in mixins.values()] + return mixins + + +class InvenTreePlugin(MixinBase, MetaBase): + """ + The InvenTreePlugin class is used to integrate with 3rd party software + + DO NOT USE THIS DIRECTLY, USE plugin.InvenTreePlugin + """ + + AUTHOR = None + DESCRIPTION = None + PUBLISH_DATE = None + VERSION = None + WEBSITE = None + LICENSE = None + + def __init__(self): super().__init__() + self.add_mixin('base') + self.def_path = inspect.getfile(self.__class__) + self.path = os.path.dirname(self.def_path) + + self.define_package() + + # region properties + @property + def description(self): + """ + Description of plugin + """ + description = getattr(self, 'DESCRIPTION', None) + if not description: + description = self.plugin_name() + return description + + @property + def author(self): + """ + Author of plugin - either from plugin settings or git + """ + author = getattr(self, 'AUTHOR', None) + if not author: + author = self.package.get('author') + if not author: + author = _('No author found') # pragma: no cover + return author + + @property + def pub_date(self): + """ + Publishing date of plugin - either from plugin settings or git + """ + pub_date = getattr(self, 'PUBLISH_DATE', None) + if not pub_date: + pub_date = self.package.get('date') + else: + pub_date = datetime.fromisoformat(str(pub_date)) + if not pub_date: + pub_date = _('No date found') # pragma: no cover + return pub_date + + @property + def version(self): + """ + Version of plugin + """ + version = getattr(self, 'VERSION', None) + return version + + @property + def website(self): + """ + Website of plugin - if set else None + """ + website = getattr(self, 'WEBSITE', None) + return website + + @property + def license(self): + """ + License of plugin + """ + lic = getattr(self, 'LICENSE', None) + return lic + # endregion + + @property + def _is_package(self): + """ + Is the plugin delivered as a package + """ + return getattr(self, 'is_package', False) + + @property + def is_sample(self): + """ + Is this plugin part of the samples? + """ + path = str(self.package_path) + return path.startswith('plugin/samples/') + + @property + def package_path(self): + """ + Path to the plugin + """ + if self._is_package: + return self.__module__ # pragma: no cover + return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR) + + @property + def settings_url(self): + """ + URL to the settings panel for this plugin + """ + return f'{reverse("settings")}#select-plugin-{self.slug}' + + # region package info + def _get_package_commit(self): + """ + Get last git commit for the plugin + """ + return get_git_log(self.def_path) + + def _get_package_metadata(self): + """ + Get package metadata for plugin + """ + return {} # pragma: no cover # TODO add usage for package metadata + + def define_package(self): + """ + Add package info of the plugin into plugins context + """ + package = self._get_package_metadata() if self._is_package else self._get_package_commit() + + # process date + if package.get('date'): + package['date'] = datetime.fromisoformat(package.get('date')) + + # process sign state + sign_state = getattr(GitStatus, str(package.get('verified')), GitStatus.N) + if sign_state.status == 0: + self.sign_color = 'success' # pragma: no cover + elif sign_state.status == 1: + self.sign_color = 'warning' + else: + self.sign_color = 'danger' # pragma: no cover + + # set variables + self.package = package + self.sign_state = sign_state + # endregion + + +class IntegrationPluginBase(InvenTreePlugin): + def __init__(self, *args, **kwargs): + """Send warning about using this reference""" + # TODO remove in 0.8.0 + warnings.warn("This import is deprecated - use InvenTreePlugin", DeprecationWarning) + super().__init__(*args, **kwargs) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 9a007307d3..6f8c9e0442 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -12,7 +12,7 @@ import os import subprocess from typing import OrderedDict -from importlib import reload +from importlib import reload, metadata from django.apps import apps from django.conf import settings @@ -22,16 +22,10 @@ from django.urls import clear_url_caches from django.contrib import admin from django.utils.text import slugify -try: - from importlib import metadata -except: # pragma: no cover - import importlib_metadata as metadata - # TODO remove when python minimum is 3.8 - from maintenance_mode.core import maintenance_mode_on from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode -from .integration import IntegrationPluginBase +from .plugin import InvenTreePlugin from .helpers import handle_error, log_error, get_plugins, IntegrationPluginError @@ -57,7 +51,6 @@ class PluginsRegistry: self.apps_loading = True # Marks if apps were reloaded yet self.git_is_modern = True # Is a modern version of git available - # integration specific self.installed_apps = [] # Holds all added plugin_paths # mixins @@ -129,7 +122,7 @@ class PluginsRegistry: log_error({error.path: error.message}, 'load') blocked_plugin = error.path # we will not try to load this app again - # Initialize apps without any integration plugins + # Initialize apps without any plugins self._clean_registry() self._clean_installed_apps() self._activate_plugins(force_reload=True) @@ -198,9 +191,7 @@ class PluginsRegistry: logger.info('Finished reloading plugins') def collect_plugins(self): - """ - Collect integration plugins from all possible ways of loading - """ + """Collect plugins from all possible ways of loading""" if not settings.PLUGINS_ENABLED: # Plugins not enabled, do nothing @@ -210,7 +201,7 @@ class PluginsRegistry: # Collect plugins from paths for plugin in settings.PLUGIN_DIRS: - modules = get_plugins(importlib.import_module(plugin), IntegrationPluginBase) + modules = get_plugins(importlib.import_module(plugin), InvenTreePlugin) if modules: [self.plugin_modules.append(item) for item in modules] @@ -236,7 +227,7 @@ class PluginsRegistry: if settings.PLUGIN_FILE_CHECKED: logger.info('Plugin file was already checked') - return + return True try: output = str(subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=os.path.dirname(settings.BASE_DIR)), 'utf-8') @@ -248,6 +239,7 @@ class PluginsRegistry: # do not run again settings.PLUGIN_FILE_CHECKED = True + return 'first_run' # endregion @@ -280,15 +272,15 @@ class PluginsRegistry: logger.info('Starting plugin initialisation') - # Initialize integration plugins + # Initialize plugins for plugin in self.plugin_modules: # Check if package was_packaged = getattr(plugin, 'is_package', False) # Check if activated # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! - plug_name = plugin.PLUGIN_NAME - plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name + plug_name = plugin.NAME + plug_key = plugin.SLUG if getattr(plugin, 'SLUG', None) else plug_name plug_key = slugify(plug_key) # keys are slugs! try: plugin_db_setting, _ = PluginConfig.objects.get_or_create(key=plug_key, name=plug_name) @@ -320,7 +312,7 @@ class PluginsRegistry: # now we can be sure that an admin has activated the plugin # TODO check more stuff -> as of Nov 2021 there are not many checks in place # but we could enhance those to check signatures, run the plugin against a whitelist etc. - logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}') + logger.info(f'Loading plugin {plug_name}') try: plugin = plugin() @@ -328,7 +320,7 @@ class PluginsRegistry: # log error and raise it -> disable plugin handle_error(error, log_name='init') - logger.debug(f'Loaded integration plugin {plugin.PLUGIN_NAME}') + logger.debug(f'Loaded plugin {plug_name}') plugin.is_package = was_packaged @@ -343,7 +335,7 @@ class PluginsRegistry: def _activate_plugins(self, force_reload=False): """ - Run integration functions for all plugins + Run activation functions for all plugins :param force_reload: force reload base apps, defaults to False :type force_reload: bool, optional @@ -352,22 +344,20 @@ class PluginsRegistry: plugins = self.plugins.items() 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) + self.activate_plugin_settings(plugins) + self.activate_plugin_schedule(plugins) + self.activate_plugin_app(plugins, force_reload=force_reload) def _deactivate_plugins(self): - """ - Run integration deactivation functions for all plugins - """ + """Run deactivation functions for all plugins""" - self.deactivate_integration_app() - self.deactivate_integration_schedule() - self.deactivate_integration_settings() + self.deactivate_plugin_app() + self.deactivate_plugin_schedule() + self.deactivate_plugin_settings() # endregion # region mixin specific loading ... - def activate_integration_settings(self, plugins): + def activate_plugin_settings(self, plugins): logger.info('Activating plugin settings') @@ -378,7 +368,7 @@ class PluginsRegistry: plugin_setting = plugin.settings self.mixins_settings[slug] = plugin_setting - def deactivate_integration_settings(self): + def deactivate_plugin_settings(self): # collect all settings plugin_settings = {} @@ -389,7 +379,7 @@ class PluginsRegistry: # clear cache self.mixins_settings = {} - def activate_integration_schedule(self, plugins): + def activate_plugin_schedule(self, plugins): logger.info('Activating plugin tasks') @@ -433,14 +423,14 @@ class PluginsRegistry: # Database might not yet be ready logger.warning("activate_integration_schedule failed, database not ready") - def deactivate_integration_schedule(self): + def deactivate_plugin_schedule(self): """ Deactivate ScheduleMixin currently nothing is done """ pass - def activate_integration_app(self, plugins, force_reload=False): + def activate_plugin_app(self, plugins, force_reload=False): """ Activate AppMixin plugins - add custom apps and reload @@ -522,13 +512,11 @@ class PluginsRegistry: plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts) except ValueError: # pragma: no cover # plugin is shipped as package - plugin_path = plugin.PLUGIN_NAME + plugin_path = plugin.NAME return plugin_path - def deactivate_integration_app(self): - """ - Deactivate integration app - some magic required - """ + def deactivate_plugin_app(self): + """Deactivate AppMixin plugins - some magic required""" # unregister models from admin for plugin_path in self.installed_apps: diff --git a/InvenTree/plugin/samples/event/__init__.py b/InvenTree/plugin/samples/event/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/samples/integration/event_sample.py b/InvenTree/plugin/samples/event/event_sample.py similarity index 66% rename from InvenTree/plugin/samples/integration/event_sample.py rename to InvenTree/plugin/samples/event/event_sample.py index dddb97da1d..5411781e05 100644 --- a/InvenTree/plugin/samples/integration/event_sample.py +++ b/InvenTree/plugin/samples/event/event_sample.py @@ -2,18 +2,18 @@ Sample plugin which responds to events """ -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import EventMixin -class EventPluginSample(EventMixin, IntegrationPluginBase): +class EventPluginSample(EventMixin, InvenTreePlugin): """ A sample plugin which provides supports for triggered events """ - PLUGIN_NAME = "EventPlugin" - PLUGIN_SLUG = "event" - PLUGIN_TITLE = "Triggered Events" + NAME = "EventPlugin" + SLUG = "event" + TITLE = "Triggered Events" def process_event(self, event, *args, **kwargs): """ Custom event processing """ diff --git a/InvenTree/plugin/samples/integration/another_sample.py b/InvenTree/plugin/samples/integration/another_sample.py index 9b3a3d8ec7..0cc5ce21c3 100644 --- a/InvenTree/plugin/samples/integration/another_sample.py +++ b/InvenTree/plugin/samples/integration/another_sample.py @@ -1,19 +1,19 @@ """sample implementation for IntegrationPlugin""" -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import UrlsMixin -class NoIntegrationPlugin(IntegrationPluginBase): +class NoIntegrationPlugin(InvenTreePlugin): """ - An basic integration plugin + An basic plugin """ - PLUGIN_NAME = "NoIntegrationPlugin" + NAME = "NoIntegrationPlugin" -class WrongIntegrationPlugin(UrlsMixin, IntegrationPluginBase): +class WrongIntegrationPlugin(UrlsMixin, InvenTreePlugin): """ - An basic integration plugin + An basic wron plugin with urls """ - PLUGIN_NAME = "WrongIntegrationPlugin" + NAME = "WrongIntegrationPlugin" diff --git a/InvenTree/plugin/samples/integration/api_caller.py b/InvenTree/plugin/samples/integration/api_caller.py index 36e1583ba0..98a145de34 100644 --- a/InvenTree/plugin/samples/integration/api_caller.py +++ b/InvenTree/plugin/samples/integration/api_caller.py @@ -1,15 +1,15 @@ """ Sample plugin for calling an external API """ -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import APICallMixin, SettingsMixin -class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase): +class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin): """ A small api call sample """ - PLUGIN_NAME = "Sample API Caller" + NAME = "Sample API Caller" SETTINGS = { 'API_TOKEN': { diff --git a/InvenTree/plugin/samples/integration/broken_file.py b/InvenTree/plugin/samples/integration/broken_file.py index c575cfb623..52c6005771 100644 --- a/InvenTree/plugin/samples/integration/broken_file.py +++ b/InvenTree/plugin/samples/integration/broken_file.py @@ -1,10 +1,10 @@ """sample of a broken python file that will be ignored on import""" -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin -class BrokenFileIntegrationPlugin(IntegrationPluginBase): +class BrokenFileIntegrationPlugin(InvenTreePlugin): """ - An very broken integration plugin + An very broken plugin """ diff --git a/InvenTree/plugin/samples/integration/broken_sample.py b/InvenTree/plugin/samples/integration/broken_sample.py index 8901d83dfd..ebd7821fe0 100644 --- a/InvenTree/plugin/samples/integration/broken_sample.py +++ b/InvenTree/plugin/samples/integration/broken_sample.py @@ -1,14 +1,14 @@ -"""sample of a broken integration plugin""" -from plugin import IntegrationPluginBase +"""sample of a broken plugin""" +from plugin import InvenTreePlugin -class BrokenIntegrationPlugin(IntegrationPluginBase): +class BrokenIntegrationPlugin(InvenTreePlugin): """ - An very broken integration plugin + An very broken plugin """ - PLUGIN_NAME = 'Test' - PLUGIN_TITLE = 'Broken Plugin' - PLUGIN_SLUG = 'broken' + NAME = 'Test' + TITLE = 'Broken Plugin' + SLUG = 'broken' def __init__(self): super().__init__() diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py index 73ca863576..0203fc4e04 100644 --- a/InvenTree/plugin/samples/integration/custom_panel_sample.py +++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py @@ -2,21 +2,21 @@ Sample plugin which renders custom panels on certain pages """ -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import PanelMixin, SettingsMixin from part.views import PartDetail from stock.views import StockLocationDetail -class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase): +class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): """ A sample plugin which renders some custom panels. """ - PLUGIN_NAME = "CustomPanelExample" - PLUGIN_SLUG = "panel" - PLUGIN_TITLE = "Custom Panel Example" + NAME = "CustomPanelExample" + SLUG = "panel" + TITLE = "Custom Panel Example" DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface" VERSION = "0.1" diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index a3a26e7609..e2f10fcabe 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -2,7 +2,7 @@ Sample implementations for IntegrationPlugin """ -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin from django.http import HttpResponse @@ -10,14 +10,14 @@ from django.utils.translation import gettext_lazy as _ from django.urls import include, re_path -class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, IntegrationPluginBase): +class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, InvenTreePlugin): """ - A full integration plugin example + A full plugin example """ - PLUGIN_NAME = "SampleIntegrationPlugin" - PLUGIN_SLUG = "sample" - PLUGIN_TITLE = "Sample Plugin" + NAME = "SampleIntegrationPlugin" + SLUG = "sample" + TITLE = "Sample Plugin" NAVIGATION_TAB_NAME = "Sample Nav" NAVIGATION_TAB_ICON = 'fas fa-plus' diff --git a/InvenTree/plugin/samples/integration/scheduled_task.py b/InvenTree/plugin/samples/integration/scheduled_task.py index 9ec70e2795..2a59f820c6 100644 --- a/InvenTree/plugin/samples/integration/scheduled_task.py +++ b/InvenTree/plugin/samples/integration/scheduled_task.py @@ -2,7 +2,7 @@ Sample plugin which supports task scheduling """ -from plugin import IntegrationPluginBase +from plugin import InvenTreePlugin from plugin.mixins import ScheduleMixin, SettingsMixin @@ -15,14 +15,14 @@ def print_world(): print("World") # pragma: no cover -class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase): +class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, InvenTreePlugin): """ A sample plugin which provides support for scheduled tasks """ - PLUGIN_NAME = "ScheduledTasksPlugin" - PLUGIN_SLUG = "schedule" - PLUGIN_TITLE = "Scheduled Tasks" + NAME = "ScheduledTasksPlugin" + SLUG = "schedule" + TITLE = "Scheduled Tasks" SCHEDULED_TASKS = { 'member': { diff --git a/InvenTree/plugin/samples/integration/test_samples_integration.py b/InvenTree/plugin/samples/integration/test_sample.py similarity index 100% rename from InvenTree/plugin/samples/integration/test_samples_integration.py rename to InvenTree/plugin/samples/integration/test_sample.py diff --git a/InvenTree/plugin/samples/integration/test_scheduled_task.py b/InvenTree/plugin/samples/integration/test_scheduled_task.py index 314f3f3f1f..a232324958 100644 --- a/InvenTree/plugin/samples/integration/test_scheduled_task.py +++ b/InvenTree/plugin/samples/integration/test_scheduled_task.py @@ -2,7 +2,7 @@ from django.test import TestCase -from plugin import registry, IntegrationPluginBase +from plugin import registry, InvenTreePlugin from plugin.helpers import MixinImplementationError from plugin.registry import call_function from plugin.mixins import ScheduleMixin @@ -45,16 +45,20 @@ class ExampleScheduledTaskPluginTests(TestCase): def test_calling(self): """check if a function can be called without errors""" + # Check with right parameters self.assertEqual(call_function('schedule', 'member_func'), False) + # Check with wrong key + self.assertEqual(call_function('does_not_exsist', 'member_func'), None) + class ScheduledTaskPluginTests(TestCase): """ Tests for ScheduledTaskPluginTests mixin base """ def test_init(self): """Check that all MixinImplementationErrors raise""" - class Base(ScheduleMixin, IntegrationPluginBase): - PLUGIN_NAME = 'APlugin' + class Base(ScheduleMixin, InvenTreePlugin): + NAME = 'APlugin' class NoSchedules(Base): """Plugin without schedules""" diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index 2f3ccee4e2..b3c0471635 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -96,8 +96,10 @@ class PluginConfigInstallSerializer(serializers.Serializer): install_name.append(f'{packagename}@{url}') else: install_name.append(url) - else: + else: # pragma: no cover # using a custom package repositories + # This is only for pypa compliant directory services (all current are tested above) + # and not covered by tests. install_name.append('-i') install_name.append(url) install_name.append(packagename) diff --git a/InvenTree/plugin/template.py b/InvenTree/plugin/template.py index 53ee7bb6db..f33b5f13fb 100644 --- a/InvenTree/plugin/template.py +++ b/InvenTree/plugin/template.py @@ -1,19 +1,12 @@ -""" -load templates for loaded plugins -""" +"""Load templates for loaded plugins""" -import logging from pathlib import Path -from django import template from django.template.loaders.filesystem import Loader as FilesystemLoader from plugin import registry -logger = logging.getLogger('inventree') - - class PluginTemplateLoader(FilesystemLoader): """ A custom template loader which allows loading of templates from installed plugins. @@ -38,25 +31,3 @@ class PluginTemplateLoader(FilesystemLoader): template_dirs.append(new_path) return tuple(template_dirs) - - -def render_template(plugin, template_file, context=None): - """ - Locate and render a template file, available in the global template context. - """ - - try: - tmp = template.loader.get_template(template_file) - except template.TemplateDoesNotExist: - logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'") - - return f""" -
- Template file {template_file} does not exist. -
- """ - - # Render with the provided context - html = tmp.render(context) - - return html diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index a30f7ec2e4..3516aab0e3 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -16,7 +16,7 @@ register = template.Library() @register.simple_tag() def plugin_list(*args, **kwargs): """ - List of all installed integration plugins + List of all installed plugins """ return registry.plugins @@ -24,7 +24,7 @@ def plugin_list(*args, **kwargs): @register.simple_tag() def inactive_plugin_list(*args, **kwargs): """ - List of all inactive integration plugins + List of all inactive plugins """ return registry.plugins_inactive diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index f04457f3f8..58911a9bd2 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -45,6 +45,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase): }, expected_code=201).data self.assertEqual(data['success'], True) + # valid - github url and packagename + data = self.post(url, { + 'confirm': True, + 'url': self.PKG_URL, + 'packagename': 'minimal', + }, expected_code=201).data + self.assertEqual(data['success'], True) + # invalid tries # no input self.post(url, {}, expected_code=400) @@ -124,3 +132,30 @@ class PluginDetailAPITest(InvenTreeAPITestCase): '_save': 'Save', }, follow=True) self.assertEqual(response.status_code, 200) + + def test_model(self): + """ + Test the PluginConfig model + """ + from plugin.models import PluginConfig + from plugin import registry + + fixtures = PluginConfig.objects.all() + + # check if plugins were registered + if not fixtures: + registry.reload_plugins() + fixtures = PluginConfig.objects.all() + + # check mixin registry + plg = fixtures.first() + mixin_dict = plg.mixins() + self.assertIn('base', mixin_dict) + self.assertDictContainsSubset({'base': {'key': 'base', 'human_name': 'base'}}, mixin_dict) + + # check reload on save + with self.assertWarns(Warning) as cm: + plg_inactive = fixtures.filter(active=False).first() + plg_inactive.active = True + plg_inactive.save() + self.assertEqual(cm.warning.args[0], 'A reload was triggered') diff --git a/InvenTree/plugin/test_helpers.py b/InvenTree/plugin/test_helpers.py new file mode 100644 index 0000000000..5187b30619 --- /dev/null +++ b/InvenTree/plugin/test_helpers.py @@ -0,0 +1,23 @@ +"""Unit tests for helpers.py""" + +from django.test import TestCase + +from .helpers import render_template + + +class HelperTests(TestCase): + """Tests for helpers""" + + def test_render_template(self): + """Check if render_template helper works""" + class ErrorSource: + slug = 'sampleplg' + + # working sample + response = render_template(ErrorSource(), 'sample/sample.html', {'abc': 123}) + self.assertEqual(response, '

123

') + + # Wrong sample + response = render_template(ErrorSource(), 'sample/wrongsample.html', {'abc': 123}) + self.assertTrue('lert alert-block alert-danger' in response) + self.assertTrue('Template file sample/wrongsample.html' in response) diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index c0835c2fb3..30054d569b 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -2,38 +2,14 @@ Unit tests for plugins """ +from datetime import datetime + from django.test import TestCase from plugin.samples.integration.sample import SampleIntegrationPlugin from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin import plugin.templatetags.plugin_extras as plugin_tags -from plugin import registry, InvenTreePluginBase - - -class InvenTreePluginTests(TestCase): - """ Tests for InvenTreePlugin """ - def setUp(self): - self.plugin = InvenTreePluginBase() - - class NamedPlugin(InvenTreePluginBase): - """a named plugin""" - PLUGIN_NAME = 'abc123' - - self.named_plugin = NamedPlugin() - - def test_basic_plugin_init(self): - """check if a basic plugin intis""" - self.assertEqual(self.plugin.PLUGIN_NAME, '') - self.assertEqual(self.plugin.plugin_name(), '') - - def test_basic_plugin_name(self): - """check if the name of a basic plugin can be set""" - self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123') - self.assertEqual(self.named_plugin.plugin_name(), 'abc123') - - def test_basic_is_active(self): - """check if a basic plugin is active""" - self.assertEqual(self.plugin.is_active(), False) +from plugin import registry, InvenTreePlugin, IntegrationPluginBase class PluginTagTests(TestCase): @@ -79,3 +55,118 @@ class PluginTagTests(TestCase): def test_tag_plugin_errors(self): """test that all errors are listed""" self.assertEqual(plugin_tags.plugin_errors(), registry.errors) + + +class InvenTreePluginTests(TestCase): + """ Tests for InvenTreePlugin """ + + def setUp(self): + self.plugin = InvenTreePlugin() + + class NamedPlugin(InvenTreePlugin): + """a named plugin""" + NAME = 'abc123' + + self.named_plugin = NamedPlugin() + + class SimpleInvenTreePlugin(InvenTreePlugin): + NAME = 'SimplePlugin' + + self.plugin_simple = SimpleInvenTreePlugin() + + class OldInvenTreePlugin(InvenTreePlugin): + PLUGIN_SLUG = 'old' + + self.plugin_old = OldInvenTreePlugin() + + class NameInvenTreePlugin(InvenTreePlugin): + NAME = 'Aplugin' + SLUG = 'a' + TITLE = 'a titel' + PUBLISH_DATE = "1111-11-11" + AUTHOR = 'AA BB' + DESCRIPTION = 'A description' + VERSION = '1.2.3a' + WEBSITE = 'http://aa.bb/cc' + LICENSE = 'MIT' + + self.plugin_name = NameInvenTreePlugin() + self.plugin_sample = SampleIntegrationPlugin() + + def test_basic_plugin_init(self): + """check if a basic plugin intis""" + self.assertEqual(self.plugin.NAME, '') + self.assertEqual(self.plugin.plugin_name(), '') + + def test_basic_plugin_name(self): + """check if the name of a basic plugin can be set""" + self.assertEqual(self.named_plugin.NAME, 'abc123') + self.assertEqual(self.named_plugin.plugin_name(), 'abc123') + + def test_basic_is_active(self): + """check if a basic plugin is active""" + self.assertEqual(self.plugin.is_active(), False) + + def test_action_name(self): + """check the name definition possibilities""" + # plugin_name + self.assertEqual(self.plugin.plugin_name(), '') + self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin') + self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin') + + # is_sampe + self.assertEqual(self.plugin.is_sample, False) + self.assertEqual(self.plugin_sample.is_sample, True) + + # slug + self.assertEqual(self.plugin.slug, '') + self.assertEqual(self.plugin_simple.slug, 'simpleplugin') + self.assertEqual(self.plugin_name.slug, 'a') + + # human_name + self.assertEqual(self.plugin.human_name, '') + self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin') + self.assertEqual(self.plugin_name.human_name, 'a titel') + + # description + self.assertEqual(self.plugin.description, '') + self.assertEqual(self.plugin_simple.description, 'SimplePlugin') + self.assertEqual(self.plugin_name.description, 'A description') + + # author + self.assertEqual(self.plugin_name.author, 'AA BB') + + # pub_date + self.assertEqual(self.plugin_name.pub_date, datetime(1111, 11, 11, 0, 0)) + + # version + self.assertEqual(self.plugin.version, None) + self.assertEqual(self.plugin_simple.version, None) + self.assertEqual(self.plugin_name.version, '1.2.3a') + + # website + self.assertEqual(self.plugin.website, None) + self.assertEqual(self.plugin_simple.website, None) + self.assertEqual(self.plugin_name.website, 'http://aa.bb/cc') + + # license + self.assertEqual(self.plugin.license, None) + self.assertEqual(self.plugin_simple.license, None) + self.assertEqual(self.plugin_name.license, 'MIT') + + def test_depreciation(self): + """Check if depreciations raise as expected""" + + # check deprecation warning is firing + with self.assertWarns(DeprecationWarning): + self.assertEqual(self.plugin_old.slug, 'old') + # check default value is used + self.assertEqual(self.plugin_old.get_meta_value('ABC', 'ABCD', '123'), '123') + + # check usage of the old class fires + class OldPlugin(IntegrationPluginBase): + pass + + with self.assertWarns(DeprecationWarning): + plg = OldPlugin() + self.assertIsInstance(plg, InvenTreePlugin) diff --git a/InvenTree/plugins/__init__.py b/InvenTree/plugins/__init__.py index ea758ff8c5..926e30e23c 100644 --- a/InvenTree/plugins/__init__.py +++ b/InvenTree/plugins/__init__.py @@ -1,2 +1,5 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals # pragma: no cover +""" +Directory for custom plugin development + +Please read the docs for more information https://inventree.readthedocs.io/en/latest/extend/plugins/#local-directory +""" diff --git a/InvenTree/templates/sample/sample.html b/InvenTree/templates/sample/sample.html new file mode 100644 index 0000000000..0db05e3152 --- /dev/null +++ b/InvenTree/templates/sample/sample.html @@ -0,0 +1 @@ +

{{abc}}

\ No newline at end of file diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 7c5c406687..7ed689f4a9 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -648,36 +648,6 @@ class Owner(models.Model): owner_type=content_type_id) except Owner.DoesNotExist: pass - else: - # Check whether user_or_group is a Group instance - try: - group = Group.objects.get(pk=user_or_group.id) - except Group.DoesNotExist: - group = None - - if group: - try: - owner = Owner.objects.get(owner_id=user_or_group.id, - owner_type=content_type_id_list[0]) - except Owner.DoesNotExist: - pass - - return owner - - # Check whether user_or_group is a User instance - try: - user = user_model.objects.get(pk=user_or_group.id) - except user_model.DoesNotExist: - user = None - - if user: - try: - owner = Owner.objects.get(owner_id=user_or_group.id, - owner_type=content_type_id_list[1]) - except Owner.DoesNotExist: - pass - - return owner return owner