mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Merge branch 'small-python-fixes-plugin' of https://github.com/matmair/InvenTree into not-working-tests
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/qc_checks.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ on: | ||||
|       - l10* | ||||
|  | ||||
| env: | ||||
|   python_version: 3.8 | ||||
|   python_version: 3.9 | ||||
|   node_version: 16 | ||||
|  | ||||
|   server_start_sleep: 60 | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/translations.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translations.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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, | ||||
|         }) | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 """ | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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()) | ||||
|   | ||||
| @@ -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)), | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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""" | ||||
|         ... | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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', | ||||
| ] | ||||
|   | ||||
| @@ -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) | ||||
| @@ -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)) | ||||
|     ) | ||||
|   | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/base/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/base/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								InvenTree/plugin/base/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/base/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										45
									
								
								InvenTree/plugin/base/action/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								InvenTree/plugin/base/action/api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|         }) | ||||
| @@ -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 | ||||
| @@ -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'} | ||||
|         ) | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/base/barcodes/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/base/barcodes/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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' | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/base/event/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/base/event/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										192
									
								
								InvenTree/plugin/base/event/events.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								InvenTree/plugin/base/event/events.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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__, | ||||
|     ) | ||||
							
								
								
									
										29
									
								
								InvenTree/plugin/base/event/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								InvenTree/plugin/base/event/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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__) | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/base/integration/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/base/integration/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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() | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/base/label/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/base/label/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										53
									
								
								InvenTree/plugin/base/label/label.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								InvenTree/plugin/base/label/label.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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] | ||||
|         ) | ||||
							
								
								
									
										39
									
								
								InvenTree/plugin/base/label/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								InvenTree/plugin/base/label/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|         raise MixinNotImplementedError('This Plugin must implement a `print_label` method') | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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): | ||||
|         """ | ||||
|   | ||||
| @@ -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') | ||||
|  | ||||
|   | ||||
| @@ -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_', | ||||
| __all__ = [ | ||||
|     'trigger_event', | ||||
| ] | ||||
|  | ||||
|     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] | ||||
|         ) | ||||
|   | ||||
| @@ -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""" | ||||
|         <div class='alert alert-block alert-danger'> | ||||
|         Template file <em>{template_file}</em> does not exist. | ||||
|         </div> | ||||
|         """ | ||||
|  | ||||
|     # Render with the provided context | ||||
|     html = tmp.render(context) | ||||
|  | ||||
|     return html | ||||
| # endregion | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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, {}) | ||||
|   | ||||
| @@ -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,23 +74,36 @@ 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: | ||||
|         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): | ||||
|         """ | ||||
|         Return the PluginConfig object associated with this plugin | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
							
								
								
									
										0
									
								
								InvenTree/plugin/samples/event/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/samples/event/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 """ | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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': { | ||||
|   | ||||
| @@ -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 | ||||
|     """ | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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__() | ||||
|   | ||||
| @@ -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" | ||||
|  | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
| @@ -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': { | ||||
|   | ||||
| @@ -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""" | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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""" | ||||
|         <div class='alert alert-block alert-danger'> | ||||
|         Template file <em>{template_file}</em> does not exist. | ||||
|         </div> | ||||
|         """ | ||||
|  | ||||
|     # Render with the provided context | ||||
|     html = tmp.render(context) | ||||
|  | ||||
|     return html | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
							
								
								
									
										23
									
								
								InvenTree/plugin/test_helpers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								InvenTree/plugin/test_helpers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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, '<h1>123</h1>') | ||||
|  | ||||
|         # Wrong sample | ||||
|         response = render_template(ErrorSource(), 'sample/wrongsample.html', {'abc': 123}) | ||||
|         self.assertTrue('lert alert-block alert-danger' in response) | ||||
|         self.assertTrue('Template file <em>sample/wrongsample.html</em>' in response) | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
| """ | ||||
|   | ||||
							
								
								
									
										1
									
								
								InvenTree/templates/sample/sample.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								InvenTree/templates/sample/sample.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <h1>{{abc}}</h1> | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user