mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Builtin plugins (#3889)
* Allow loading of "builtin" plugins, even if "plugins" are not explicitly loaded
* Updates for 'admin' buttons:
- Make them work like proper links
- Hidden if 'hide_admin_link' customization option is set
- Check for user staff status
* Cleanup rendering of "plugins" display
* Consolidate InvenTree barcode plugins into single plugin class
* Hide "install plugin" button if plugins are not enabled
* Add info message is external plugins are not enabled
* Fixes for loading plugins
- Always load 'builtin' plugins
- Refactor calls to "is_active" at various points in codebase
* Various tweaks
- Improve builtin plugin descriptions
- Spelling fixes
* Adjust plugin detail for builtin plugins
* Simplify barcode plugin class
* Simplify template rendering
* Bug fix for inventree barcode plugin
* Revert "Simplify template rendering"
This reverts commit 3a6755a659.
* Re-re-improve template rendering
- Required as the template has been refactored for both "active" and "inactive" plugins
* Fixing unit tests for barcode plugin
* Ensure that barcode scan actions do not take a "long time":
- Add a default timeout of 0.1s to any POST or GET request in the testing framework
- Can be overridden by calling method if desired
* Display plugin "builtin" status in admin panel
* Fix unit tests for plugin API
* Further unit testing fixes
* Version number tweaks
* Further tweaks for unit testing
* Allow longer timeout for report printing via API
* Increase default timeout for API tests
- Sometimes CPU spike can cause the test to fail :|
* label printing can take a bit longer
* Remove timeout requirement from API tester
- Too variable to be reliable for CI
			
			
This commit is contained in:
		| @@ -134,7 +134,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): | |||||||
|         if expected_code is not None: |         if expected_code is not None: | ||||||
|  |  | ||||||
|             if response.status_code != expected_code: |             if response.status_code != expected_code: | ||||||
|                 print(f"Unexpected response at '{url}':") |                 print(f"Unexpected response at '{url}': status_code = {response.status_code}") | ||||||
|                 print(response.data) |                 print(response.data) | ||||||
|  |  | ||||||
|             self.assertEqual(response.status_code, expected_code) |             self.assertEqual(response.status_code, expected_code) | ||||||
| @@ -143,11 +143,13 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): | |||||||
|  |  | ||||||
|     def post(self, url, data=None, expected_code=None, format='json'): |     def post(self, url, data=None, expected_code=None, format='json'): | ||||||
|         """Issue a POST request.""" |         """Issue a POST request.""" | ||||||
|         response = self.client.post(url, data=data, format=format) |  | ||||||
|  |  | ||||||
|  |         # Set default value - see B006 | ||||||
|         if data is None: |         if data is None: | ||||||
|             data = {} |             data = {} | ||||||
|  |  | ||||||
|  |         response = self.client.post(url, data=data, format=format) | ||||||
|  |  | ||||||
|         if expected_code is not None: |         if expected_code is not None: | ||||||
|  |  | ||||||
|             if response.status_code != expected_code: |             if response.status_code != expected_code: | ||||||
|   | |||||||
| @@ -127,13 +127,6 @@ function inventreeDocReady() { | |||||||
|         loadBrandIcon($(this), $(this).attr('brand_name')); |         loadBrandIcon($(this), $(this).attr('brand_name')); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     // Callback for "admin view" button |  | ||||||
|     $('#admin-button, .admin-button').click(function() { |  | ||||||
|         var url = $(this).attr('url'); |  | ||||||
|  |  | ||||||
|         location.href = url; |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     // Display any cached alert messages |     // Display any cached alert messages | ||||||
|     showCachedAlerts(); |     showCachedAlerts(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -178,11 +178,15 @@ class APITests(InvenTreeAPITestCase): | |||||||
|     def test_with_roles(self): |     def test_with_roles(self): | ||||||
|         """Assign some roles to the user.""" |         """Assign some roles to the user.""" | ||||||
|         self.basicAuth() |         self.basicAuth() | ||||||
|         response = self.get(reverse('api-user-roles')) |  | ||||||
|  |         url = reverse('api-user-roles') | ||||||
|  |  | ||||||
|  |         response = self.get(url) | ||||||
|  |  | ||||||
|         self.assignRole('part.delete') |         self.assignRole('part.delete') | ||||||
|         self.assignRole('build.change') |         self.assignRole('build.change') | ||||||
|         response = self.get(reverse('api-user-roles')) |  | ||||||
|  |         response = self.get(url) | ||||||
|  |  | ||||||
|         roles = response.data['roles'] |         roles = response.data['roles'] | ||||||
|  |  | ||||||
|   | |||||||
| @@ -213,7 +213,7 @@ class BuildTest(BuildAPITest): | |||||||
|                 "location": 1, |                 "location": 1, | ||||||
|                 "status": 50,  # Item requires attention |                 "status": 50,  # Item requires attention | ||||||
|             }, |             }, | ||||||
|             expected_code=201 |             expected_code=201, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.assertEqual(self.build.incomplete_outputs.count(), 0) |         self.assertEqual(self.build.incomplete_outputs.count(), 0) | ||||||
|   | |||||||
| @@ -1332,7 +1332,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): | |||||||
|  |  | ||||||
|         'PLUGIN_ON_STARTUP': { |         'PLUGIN_ON_STARTUP': { | ||||||
|             'name': _('Check plugins on startup'), |             'name': _('Check plugins on startup'), | ||||||
|             'description': _('Check that all plugins are installed on startup - enable in container enviroments'), |             'description': _('Check that all plugins are installed on startup - enable in container environments'), | ||||||
|             'default': False, |             'default': False, | ||||||
|             'validator': bool, |             'validator': bool, | ||||||
|             'requires_restart': True, |             'requires_restart': True, | ||||||
|   | |||||||
| @@ -67,9 +67,7 @@ class LabelPrintMixin: | |||||||
|         plugin = registry.get_plugin(plugin_key) |         plugin = registry.get_plugin(plugin_key) | ||||||
|  |  | ||||||
|         if plugin: |         if plugin: | ||||||
|             config = plugin.plugin_config() |             if plugin.is_active(): | ||||||
|  |  | ||||||
|             if config and config.active: |  | ||||||
|                 # Only return the plugin if it is enabled! |                 # Only return the plugin if it is enabled! | ||||||
|                 return plugin |                 return plugin | ||||||
|             else: |             else: | ||||||
|   | |||||||
| @@ -593,7 +593,7 @@ class PartAPITest(InvenTreeAPITestCase): | |||||||
|                 { |                 { | ||||||
|                     'convert_from': variant.pk, |                     'convert_from': variant.pk, | ||||||
|                 }, |                 }, | ||||||
|                 expected_code=200 |                 expected_code=200, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             # There should be the same number of results for each request |             # There should be the same number of results for each request | ||||||
| @@ -1854,7 +1854,7 @@ class BomItemTest(InvenTreeAPITestCase): | |||||||
|             data={ |             data={ | ||||||
|                 'validated': True, |                 'validated': True, | ||||||
|             }, |             }, | ||||||
|             expected_code=200 |             expected_code=200, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Check that the expected response is returned |         # Check that the expected response is returned | ||||||
|   | |||||||
| @@ -52,7 +52,7 @@ class PluginConfigAdmin(admin.ModelAdmin): | |||||||
|     """Custom admin with restricted id fields.""" |     """Custom admin with restricted id fields.""" | ||||||
|  |  | ||||||
|     readonly_fields = ["key", "name", ] |     readonly_fields = ["key", "name", ] | ||||||
|     list_display = ['name', 'key', '__str__', 'active', 'is_sample'] |     list_display = ['name', 'key', '__str__', 'active', 'is_builtin', 'is_sample'] | ||||||
|     list_filter = ['active'] |     list_filter = ['active'] | ||||||
|     actions = [plugin_activate, plugin_deactivate, ] |     actions = [plugin_activate, plugin_deactivate, ] | ||||||
|     inlines = [PluginSettingInline, ] |     inlines = [PluginSettingInline, ] | ||||||
|   | |||||||
| @@ -7,7 +7,6 @@ The main code for plugin special sauce is in the plugin registry in `InvenTree/p | |||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.conf import settings |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
| from maintenance_mode.core import set_maintenance_mode | from maintenance_mode.core import set_maintenance_mode | ||||||
| @@ -26,34 +25,34 @@ class PluginAppConfig(AppConfig): | |||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         """The ready method is extended to initialize plugins.""" |         """The ready method is extended to initialize plugins.""" | ||||||
|         if settings.PLUGINS_ENABLED: |         if not canAppAccessDatabase(allow_test=True, allow_plugins=True): | ||||||
|             if not canAppAccessDatabase(allow_test=True, allow_plugins=True): |             logger.info("Skipping plugin loading sequence")  # pragma: no cover | ||||||
|                 logger.info("Skipping plugin loading sequence")  # pragma: no cover |         else: | ||||||
|             else: |             logger.info('Loading InvenTree plugins') | ||||||
|                 logger.info('Loading InvenTree plugins') |  | ||||||
|  |  | ||||||
|                 if not registry.is_loading: |             if not registry.is_loading: | ||||||
|                     # this is the first startup |                 # this is the first startup | ||||||
|                     try: |                 try: | ||||||
|                         from common.models import InvenTreeSetting |                     from common.models import InvenTreeSetting | ||||||
|                         if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): |                     if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): | ||||||
|                             # make sure all plugins are installed |                         # make sure all plugins are installed | ||||||
|                             registry.install_plugin_file() |                         registry.install_plugin_file() | ||||||
|                     except Exception:  # pragma: no cover |                 except Exception:  # pragma: no cover | ||||||
|                         pass |                     pass | ||||||
|  |  | ||||||
|                     # get plugins and init them |                 # get plugins and init them | ||||||
|                     registry.plugin_modules = registry.collect_plugins() |                 registry.plugin_modules = registry.collect_plugins() | ||||||
|                     registry.load_plugins() |                 registry.load_plugins() | ||||||
|  |  | ||||||
|                     # drop out of maintenance |                 # drop out of maintenance | ||||||
|                     # makes sure we did not have an error in reloading and maintenance is still active |                 # makes sure we did not have an error in reloading and maintenance is still active | ||||||
|                     set_maintenance_mode(False) |                 set_maintenance_mode(False) | ||||||
|  |  | ||||||
|             # check git version |         # check git version | ||||||
|             registry.git_is_modern = check_git_version() |         registry.git_is_modern = check_git_version() | ||||||
|             if not registry.git_is_modern:  # pragma: no cover  # simulating old git seems not worth it for coverage |  | ||||||
|                 log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') |         if not registry.git_is_modern:  # pragma: no cover  # simulating old git seems not worth it for coverage | ||||||
|  |             log_error(_('Your environment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') | ||||||
|  |  | ||||||
|         else: |         else: | ||||||
|             logger.info("Plugins not enabled - skipping loading sequence")  # pragma: no cover |             logger.info("Plugins not enabled - skipping loading sequence")  # pragma: no cover | ||||||
|   | |||||||
| @@ -11,8 +11,8 @@ from rest_framework.views import APIView | |||||||
|  |  | ||||||
| from InvenTree.helpers import hash_barcode | from InvenTree.helpers import hash_barcode | ||||||
| from plugin import registry | from plugin import registry | ||||||
| from plugin.builtin.barcodes.inventree_barcode import ( | from plugin.builtin.barcodes.inventree_barcode import \ | ||||||
|     InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin) |     InvenTreeInternalBarcodePlugin | ||||||
| from users.models import RuleSet | from users.models import RuleSet | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -53,11 +53,8 @@ class BarcodeScan(APIView): | |||||||
|         if not barcode_data: |         if not barcode_data: | ||||||
|             raise ValidationError({'barcode': _('Missing barcode data')}) |             raise ValidationError({'barcode': _('Missing barcode data')}) | ||||||
|  |  | ||||||
|         # Ensure that the default barcode handlers are run first |         # Note: the default barcode handlers are loaded (and thus run) first | ||||||
|         plugins = [ |         plugins = registry.with_mixin('barcode') | ||||||
|             InvenTreeInternalBarcodePlugin(), |  | ||||||
|             InvenTreeExternalBarcodePlugin(), |  | ||||||
|         ] + registry.with_mixin('barcode') |  | ||||||
|  |  | ||||||
|         barcode_hash = hash_barcode(barcode_data) |         barcode_hash = hash_barcode(barcode_data) | ||||||
|  |  | ||||||
| @@ -113,10 +110,7 @@ class BarcodeAssign(APIView): | |||||||
|             raise ValidationError({'barcode': _('Missing barcode data')}) |             raise ValidationError({'barcode': _('Missing barcode data')}) | ||||||
|  |  | ||||||
|         # Here we only check against 'InvenTree' plugins |         # Here we only check against 'InvenTree' plugins | ||||||
|         plugins = [ |         plugins = registry.with_mixin('barcode', builtin=True) | ||||||
|             InvenTreeInternalBarcodePlugin(), |  | ||||||
|             InvenTreeExternalBarcodePlugin(), |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|         # First check if the provided barcode matches an existing database entry |         # First check if the provided barcode matches an existing database entry | ||||||
|         for plugin in plugins: |         for plugin in plugins: | ||||||
| @@ -133,7 +127,7 @@ class BarcodeAssign(APIView): | |||||||
|  |  | ||||||
|         valid_labels = [] |         valid_labels = [] | ||||||
|  |  | ||||||
|         for model in InvenTreeExternalBarcodePlugin.get_supported_barcode_models(): |         for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models(): | ||||||
|             label = model.barcode_model_type() |             label = model.barcode_model_type() | ||||||
|             valid_labels.append(label) |             valid_labels.append(label) | ||||||
|  |  | ||||||
| @@ -188,7 +182,7 @@ class BarcodeUnassign(APIView): | |||||||
|         """Respond to a barcode unassign POST request""" |         """Respond to a barcode unassign POST request""" | ||||||
|  |  | ||||||
|         # The following database models support assignment of third-party barcodes |         # The following database models support assignment of third-party barcodes | ||||||
|         supported_models = InvenTreeExternalBarcodePlugin.get_supported_barcode_models() |         supported_models = InvenTreeInternalBarcodePlugin.get_supported_barcode_models() | ||||||
|  |  | ||||||
|         supported_labels = [model.barcode_model_type() for model in supported_models] |         supported_labels = [model.barcode_model_type() for model in supported_models] | ||||||
|         model_names = ', '.join(supported_labels) |         model_names = ', '.join(supported_labels) | ||||||
|   | |||||||
| @@ -58,9 +58,8 @@ def register_event(event, *args, **kwargs): | |||||||
|  |  | ||||||
|                 if plugin.mixin_enabled('events'): |                 if plugin.mixin_enabled('events'): | ||||||
|  |  | ||||||
|                     config = plugin.plugin_config() |                     if plugin.is_active(): | ||||||
|  |                         # Only allow event registering for 'active' plugins | ||||||
|                     if config and config.active: |  | ||||||
|  |  | ||||||
|                         logger.debug(f"Registering callback for plugin '{slug}'") |                         logger.debug(f"Registering callback for plugin '{slug}'") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -9,6 +9,8 @@ references model objects actually exist in the database. | |||||||
|  |  | ||||||
| import json | import json | ||||||
|  |  | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
| from company.models import SupplierPart | from company.models import SupplierPart | ||||||
| from InvenTree.helpers import hash_barcode | from InvenTree.helpers import hash_barcode | ||||||
| from part.models import Part | from part.models import Part | ||||||
| @@ -17,8 +19,14 @@ from plugin.mixins import BarcodeMixin | |||||||
| from stock.models import StockItem, StockLocation | from stock.models import StockItem, StockLocation | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): | class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin): | ||||||
|     """Generic base class for handling InvenTree barcodes""" |     """Builtin BarcodePlugin for matching and generating internal barcodes.""" | ||||||
|  |  | ||||||
|  |     NAME = "InvenTreeBarcode" | ||||||
|  |     TITLE = _("Inventree Barcodes") | ||||||
|  |     DESCRIPTION = _("Provides native support for barcodes") | ||||||
|  |     VERSION = "2.0.0" | ||||||
|  |     AUTHOR = _("InvenTree contributors") | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def get_supported_barcode_models(): |     def get_supported_barcode_models(): | ||||||
| @@ -58,57 +66,42 @@ class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): | |||||||
|  |  | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvenTreeInternalBarcodePlugin(InvenTreeBarcodePlugin): |  | ||||||
|     """Builtin BarcodePlugin for matching and generating internal barcodes.""" |  | ||||||
|  |  | ||||||
|     NAME = "InvenTreeInternalBarcode" |  | ||||||
|  |  | ||||||
|     def scan(self, barcode_data): |     def scan(self, barcode_data): | ||||||
|         """Scan a barcode against this plugin. |         """Scan a barcode against this plugin. | ||||||
|  |  | ||||||
|         Here we are looking for a dict object which contains a reference to a particular InvenTree database object |         Here we are looking for a dict object which contains a reference to a particular InvenTree database object | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|  |         # Create hash from raw barcode data | ||||||
|  |         barcode_hash = hash_barcode(barcode_data) | ||||||
|  |  | ||||||
|  |         # Attempt to coerce the barcode data into a dict object | ||||||
|  |         # This is the internal barcode representation that InvenTree uses | ||||||
|  |         barcode_dict = None | ||||||
|  |  | ||||||
|         if type(barcode_data) is dict: |         if type(barcode_data) is dict: | ||||||
|             pass |             barcode_dict = barcode_data | ||||||
|         elif type(barcode_data) is str: |         elif type(barcode_data) is str: | ||||||
|             try: |             try: | ||||||
|                 barcode_data = json.loads(barcode_data) |                 barcode_dict = json.loads(barcode_data) | ||||||
|             except json.JSONDecodeError: |             except json.JSONDecodeError: | ||||||
|                 return None |                 pass | ||||||
|         else: |  | ||||||
|             return None |  | ||||||
|  |  | ||||||
|         if type(barcode_data) is not dict: |         if barcode_dict is not None and type(barcode_dict) is dict: | ||||||
|             return None |             # Look for various matches. First good match will be returned | ||||||
|  |             for model in self.get_supported_barcode_models(): | ||||||
|  |                 label = model.barcode_model_type() | ||||||
|  |  | ||||||
|         # Look for various matches. First good match will be returned |                 if label in barcode_dict: | ||||||
|  |                     try: | ||||||
|  |                         instance = model.objects.get(pk=barcode_dict[label]) | ||||||
|  |                         return self.format_matched_response(label, model, instance) | ||||||
|  |                     except (ValueError, model.DoesNotExist): | ||||||
|  |                         pass | ||||||
|  |  | ||||||
|  |         # If no "direct" hits are found, look for assigned third-party barcodes | ||||||
|         for model in self.get_supported_barcode_models(): |         for model in self.get_supported_barcode_models(): | ||||||
|             label = model.barcode_model_type() |             label = model.barcode_model_type() | ||||||
|             if label in barcode_data: |  | ||||||
|                 try: |  | ||||||
|                     instance = model.objects.get(pk=barcode_data[label]) |  | ||||||
|                     return self.format_matched_response(label, model, instance) |  | ||||||
|                 except (ValueError, model.DoesNotExist): |  | ||||||
|                     pass |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin): |  | ||||||
|     """Builtin BarcodePlugin for matching arbitrary external barcodes.""" |  | ||||||
|  |  | ||||||
|     NAME = "InvenTreeExternalBarcode" |  | ||||||
|  |  | ||||||
|     def scan(self, barcode_data): |  | ||||||
|         """Scan a barcode against this plugin. |  | ||||||
|  |  | ||||||
|         Here we are looking for a dict object which contains a reference to a particular InvenTree databse object |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         for model in self.get_supported_barcode_models(): |  | ||||||
|             label = model.barcode_model_type() |  | ||||||
|  |  | ||||||
|             barcode_hash = hash_barcode(barcode_data) |  | ||||||
|  |  | ||||||
|             instance = model.lookup_barcode(barcode_hash) |             instance = model.lookup_barcode(barcode_hash) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -29,7 +29,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): | |||||||
|                     'barcode': barcode_data, |                     'barcode': barcode_data, | ||||||
|                     'stockitem': 521 |                     'stockitem': 521 | ||||||
|                 }, |                 }, | ||||||
|                 expected_code=400 |                 expected_code=400, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             self.assertIn('error', response.data) |             self.assertIn('error', response.data) | ||||||
| @@ -250,7 +250,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.assertIn('success', response.data) |         self.assertIn('success', response.data) | ||||||
|         self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode') |         self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') | ||||||
|         self.assertEqual(response.data['part']['pk'], 1) |         self.assertEqual(response.data['part']['pk'], 1) | ||||||
|  |  | ||||||
|         # Attempting to assign the same barcode to a different part should result in an error |         # Attempting to assign the same barcode to a different part should result in an error | ||||||
| @@ -347,7 +347,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): | |||||||
|         response = self.scan({'barcode': 'blbla=10004'}, expected_code=200) |         response = self.scan({'barcode': 'blbla=10004'}, expected_code=200) | ||||||
|  |  | ||||||
|         self.assertEqual(response.data['barcode_data'], 'blbla=10004') |         self.assertEqual(response.data['barcode_data'], 'blbla=10004') | ||||||
|         self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode') |         self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') | ||||||
|  |  | ||||||
|         # Scan for a StockItem instance |         # Scan for a StockItem instance | ||||||
|         si = stock.models.StockItem.objects.get(pk=1) |         si = stock.models.StockItem.objects.get(pk=1) | ||||||
| @@ -402,7 +402,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): | |||||||
|         self.assertEqual(response.data['stocklocation']['pk'], 5) |         self.assertEqual(response.data['stocklocation']['pk'], 5) | ||||||
|         self.assertEqual(response.data['stocklocation']['api_url'], '/api/stock/location/5/') |         self.assertEqual(response.data['stocklocation']['api_url'], '/api/stock/location/5/') | ||||||
|         self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/') |         self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/') | ||||||
|         self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode') |         self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') | ||||||
|  |  | ||||||
|         # Scan a Part object |         # Scan a Part object | ||||||
|         response = self.scan( |         response = self.scan( | ||||||
| @@ -423,7 +423,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         self.assertEqual(response.data['supplierpart']['pk'], 1) |         self.assertEqual(response.data['supplierpart']['pk'], 1) | ||||||
|         self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode') |         self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') | ||||||
|  |  | ||||||
|         self.assertIn('success', response.data) |         self.assertIn('success', response.data) | ||||||
|         self.assertIn('barcode_data', response.data) |         self.assertIn('barcode_data', response.data) | ||||||
|   | |||||||
| @@ -27,8 +27,10 @@ class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin): | |||||||
|     """Core notification methods for InvenTree.""" |     """Core notification methods for InvenTree.""" | ||||||
|  |  | ||||||
|     NAME = "CoreNotificationsPlugin" |     NAME = "CoreNotificationsPlugin" | ||||||
|  |     TITLE = _("InvenTree Notifications") | ||||||
|     AUTHOR = _('InvenTree contributors') |     AUTHOR = _('InvenTree contributors') | ||||||
|     DESCRIPTION = _('Integrated outgoing notificaton methods') |     DESCRIPTION = _('Integrated outgoing notificaton methods') | ||||||
|  |     VERSION = "1.0.0" | ||||||
|  |  | ||||||
|     SETTINGS = { |     SETTINGS = { | ||||||
|         'ENABLE_NOTIFICATION_EMAILS': { |         'ENABLE_NOTIFICATION_EMAILS': { | ||||||
|   | |||||||
| @@ -158,16 +158,20 @@ class PluginConfig(models.Model): | |||||||
|     @admin.display(boolean=True, description=_('Sample plugin')) |     @admin.display(boolean=True, description=_('Sample plugin')) | ||||||
|     def is_sample(self) -> bool: |     def is_sample(self) -> bool: | ||||||
|         """Is this plugin a sample app?""" |         """Is this plugin a sample app?""" | ||||||
|         # Loaded and active plugin |  | ||||||
|         if isinstance(self.plugin, InvenTreePlugin): |  | ||||||
|             return self.plugin.check_is_sample() |  | ||||||
|  |  | ||||||
|         # If no plugin_class is available it can not be a sample |  | ||||||
|         if not self.plugin: |         if not self.plugin: | ||||||
|             return False |             return False | ||||||
|  |  | ||||||
|         # Not loaded plugin |         return self.plugin.check_is_sample() | ||||||
|         return self.plugin.check_is_sample()  # pragma: no cover |  | ||||||
|  |     @admin.display(boolean=True, description=_('Builtin Plugin')) | ||||||
|  |     def is_builtin(self) -> bool: | ||||||
|  |         """Return True if this is a 'builtin' plugin""" | ||||||
|  |  | ||||||
|  |         if not self.plugin: | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         return self.plugin.check_is_builtin() | ||||||
|  |  | ||||||
|  |  | ||||||
| class PluginSetting(common.models.BaseInvenTreeSetting): | class PluginSetting(common.models.BaseInvenTreeSetting): | ||||||
|   | |||||||
| @@ -106,10 +106,15 @@ class MetaBase: | |||||||
|  |  | ||||||
|     def is_active(self): |     def is_active(self): | ||||||
|         """Return True if this plugin is currently active.""" |         """Return True if this plugin is currently active.""" | ||||||
|         cfg = self.plugin_config() |  | ||||||
|  |  | ||||||
|         if cfg: |         # Builtin plugins are always considered "active" | ||||||
|             return cfg.active |         if self.is_builtin: | ||||||
|  |             return True | ||||||
|  |  | ||||||
|  |         config = self.plugin_config() | ||||||
|  |  | ||||||
|  |         if config: | ||||||
|  |             return config.active | ||||||
|         else: |         else: | ||||||
|             return False  # pragma: no cover |             return False  # pragma: no cover | ||||||
|  |  | ||||||
| @@ -300,6 +305,16 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): | |||||||
|         """Is this plugin part of the samples?""" |         """Is this plugin part of the samples?""" | ||||||
|         return self.check_is_sample() |         return self.check_is_sample() | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def check_is_builtin(cls) -> bool: | ||||||
|  |         """Determine if a particular plugin class is a 'builtin' plugin""" | ||||||
|  |         return str(cls.check_package_path()).startswith('plugin/builtin') | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_builtin(self) -> bool: | ||||||
|  |         """Is this plugin is builtin""" | ||||||
|  |         return self.check_is_builtin() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def check_package_path(cls): |     def check_package_path(cls): | ||||||
|         """Path to the plugin.""" |         """Path to the plugin.""" | ||||||
|   | |||||||
| @@ -108,9 +108,6 @@ class PluginsRegistry: | |||||||
|         Args: |         Args: | ||||||
|             full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. |             full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. | ||||||
|         """ |         """ | ||||||
|         if not settings.PLUGINS_ENABLED: |  | ||||||
|             # Plugins not enabled, do nothing |  | ||||||
|             return  # pragma: no cover |  | ||||||
|  |  | ||||||
|         logger.info('Start loading plugins') |         logger.info('Start loading plugins') | ||||||
|  |  | ||||||
| @@ -167,9 +164,6 @@ class PluginsRegistry: | |||||||
|  |  | ||||||
|     def unload_plugins(self): |     def unload_plugins(self): | ||||||
|         """Unload and deactivate all IntegrationPlugins.""" |         """Unload and deactivate all IntegrationPlugins.""" | ||||||
|         if not settings.PLUGINS_ENABLED: |  | ||||||
|             # Plugins not enabled, do nothing |  | ||||||
|             return  # pragma: no cover |  | ||||||
|  |  | ||||||
|         logger.info('Start unloading plugins') |         logger.info('Start unloading plugins') | ||||||
|  |  | ||||||
| @@ -187,6 +181,7 @@ class PluginsRegistry: | |||||||
|         # remove maintenance |         # remove maintenance | ||||||
|         if not _maintenance: |         if not _maintenance: | ||||||
|             set_maintenance_mode(False)  # pragma: no cover |             set_maintenance_mode(False)  # pragma: no cover | ||||||
|  |  | ||||||
|         logger.info('Finished unloading plugins') |         logger.info('Finished unloading plugins') | ||||||
|  |  | ||||||
|     def reload_plugins(self, full_reload: bool = False): |     def reload_plugins(self, full_reload: bool = False): | ||||||
| @@ -210,62 +205,63 @@ class PluginsRegistry: | |||||||
|     def plugin_dirs(self): |     def plugin_dirs(self): | ||||||
|         """Construct a list of directories from where plugins can be loaded""" |         """Construct a list of directories from where plugins can be loaded""" | ||||||
|  |  | ||||||
|  |         # Builtin plugins are *always* loaded | ||||||
|         dirs = ['plugin.builtin', ] |         dirs = ['plugin.builtin', ] | ||||||
|  |  | ||||||
|         if settings.TESTING or settings.DEBUG: |         if settings.PLUGINS_ENABLED: | ||||||
|             # If in TEST or DEBUG mode, load plugins from the 'samples' directory |             # Any 'external' plugins are only loaded if PLUGINS_ENABLED is set to True | ||||||
|             dirs.append('plugin.samples') |  | ||||||
|  |  | ||||||
|         if settings.TESTING: |             if settings.TESTING or settings.DEBUG: | ||||||
|             custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) |                 # If in TEST or DEBUG mode, load plugins from the 'samples' directory | ||||||
|         else:  # pragma: no cover |                 dirs.append('plugin.samples') | ||||||
|             custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') |  | ||||||
|  |  | ||||||
|             # Load from user specified directories (unless in testing mode) |             if settings.TESTING: | ||||||
|             dirs.append('plugins') |                 custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) | ||||||
|  |             else:  # pragma: no cover | ||||||
|  |                 custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') | ||||||
|  |  | ||||||
|         if custom_dirs is not None: |                 # Load from user specified directories (unless in testing mode) | ||||||
|             # Allow multiple plugin directories to be specified |                 dirs.append('plugins') | ||||||
|             for pd_text in custom_dirs.split(','): |  | ||||||
|                 pd = Path(pd_text.strip()).absolute() |  | ||||||
|  |  | ||||||
|                 # Attempt to create the directory if it does not already exist |             if custom_dirs is not None: | ||||||
|                 if not pd.exists(): |                 # Allow multiple plugin directories to be specified | ||||||
|                     try: |                 for pd_text in custom_dirs.split(','): | ||||||
|                         pd.mkdir(exist_ok=True) |                     pd = Path(pd_text.strip()).absolute() | ||||||
|                     except Exception:  # pragma: no cover |  | ||||||
|                         logger.error(f"Could not create plugin directory '{pd}'") |  | ||||||
|                         continue |  | ||||||
|  |  | ||||||
|                 # Ensure the directory has an __init__.py file |                     # Attempt to create the directory if it does not already exist | ||||||
|                 init_filename = pd.joinpath('__init__.py') |                     if not pd.exists(): | ||||||
|  |                         try: | ||||||
|  |                             pd.mkdir(exist_ok=True) | ||||||
|  |                         except Exception:  # pragma: no cover | ||||||
|  |                             logger.error(f"Could not create plugin directory '{pd}'") | ||||||
|  |                             continue | ||||||
|  |  | ||||||
|                 if not init_filename.exists(): |                     # Ensure the directory has an __init__.py file | ||||||
|                     try: |                     init_filename = pd.joinpath('__init__.py') | ||||||
|                         init_filename.write_text("# InvenTree plugin directory\n") |  | ||||||
|                     except Exception:  # pragma: no cover |  | ||||||
|                         logger.error(f"Could not create file '{init_filename}'") |  | ||||||
|                         continue |  | ||||||
|  |  | ||||||
|                 # By this point, we have confirmed that the directory at least exists |                     if not init_filename.exists(): | ||||||
|                 if pd.exists() and pd.is_dir(): |                         try: | ||||||
|                     # Convert to python dot-path |                             init_filename.write_text("# InvenTree plugin directory\n") | ||||||
|                     if pd.is_relative_to(settings.BASE_DIR): |                         except Exception:  # pragma: no cover | ||||||
|                         pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts) |                             logger.error(f"Could not create file '{init_filename}'") | ||||||
|                     else: |                             continue | ||||||
|                         pd_path = str(pd) |  | ||||||
|  |  | ||||||
|                     # Add path |                     # By this point, we have confirmed that the directory at least exists | ||||||
|                     dirs.append(pd_path) |                     if pd.exists() and pd.is_dir(): | ||||||
|                     logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'") |                         # Convert to python dot-path | ||||||
|  |                         if pd.is_relative_to(settings.BASE_DIR): | ||||||
|  |                             pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts) | ||||||
|  |                         else: | ||||||
|  |                             pd_path = str(pd) | ||||||
|  |  | ||||||
|  |                         # Add path | ||||||
|  |                         dirs.append(pd_path) | ||||||
|  |                         logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'") | ||||||
|  |  | ||||||
|         return dirs |         return dirs | ||||||
|  |  | ||||||
|     def collect_plugins(self): |     def collect_plugins(self): | ||||||
|         """Collect plugins from all possible ways of loading. Returned as list.""" |         """Collect plugins from all possible ways of loading. Returned as list.""" | ||||||
|         if not settings.PLUGINS_ENABLED: |  | ||||||
|             # Plugins not enabled, do nothing |  | ||||||
|             return  # pragma: no cover |  | ||||||
|  |  | ||||||
|         collected_plugins = [] |         collected_plugins = [] | ||||||
|  |  | ||||||
| @@ -293,17 +289,20 @@ class PluginsRegistry: | |||||||
|             if modules: |             if modules: | ||||||
|                 [collected_plugins.append(item) for item in modules] |                 [collected_plugins.append(item) for item in modules] | ||||||
|  |  | ||||||
|         # Check if not running in testing mode and apps should be loaded from hooks |         # From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled | ||||||
|         if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): |         if settings.PLUGINS_ENABLED: | ||||||
|             # Collect plugins from setup entry points |  | ||||||
|             for entry in get_entrypoints(): |             # Check if not running in testing mode and apps should be loaded from hooks | ||||||
|                 try: |             if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): | ||||||
|                     plugin = entry.load() |                 # Collect plugins from setup entry points | ||||||
|                     plugin.is_package = True |                 for entry in get_entrypoints(): | ||||||
|                     plugin._get_package_metadata() |                     try: | ||||||
|                     collected_plugins.append(plugin) |                         plugin = entry.load() | ||||||
|                 except Exception as error:  # pragma: no cover |                         plugin.is_package = True | ||||||
|                     handle_error(error, do_raise=False, log_name='discovery') |                         plugin._get_package_metadata() | ||||||
|  |                         collected_plugins.append(plugin) | ||||||
|  |                     except Exception as error:  # pragma: no cover | ||||||
|  |                         handle_error(error, do_raise=False, log_name='discovery') | ||||||
|  |  | ||||||
|         # Log collected plugins |         # Log collected plugins | ||||||
|         logger.info(f'Collected {len(collected_plugins)} plugins!') |         logger.info(f'Collected {len(collected_plugins)} plugins!') | ||||||
| @@ -335,7 +334,7 @@ class PluginsRegistry: | |||||||
|     # endregion |     # endregion | ||||||
|  |  | ||||||
|     # region registry functions |     # region registry functions | ||||||
|     def with_mixin(self, mixin: str, active=None): |     def with_mixin(self, mixin: str, active=None, builtin=None): | ||||||
|         """Returns reference to all plugins that have a specified mixin enabled.""" |         """Returns reference to all plugins that have a specified mixin enabled.""" | ||||||
|         result = [] |         result = [] | ||||||
|  |  | ||||||
| @@ -343,10 +342,13 @@ class PluginsRegistry: | |||||||
|             if plugin.mixin_enabled(mixin): |             if plugin.mixin_enabled(mixin): | ||||||
|  |  | ||||||
|                 if active is not None: |                 if active is not None: | ||||||
|                     # Filter by 'enabled' status |                     # Filter by 'active' status of plugin | ||||||
|                     config = plugin.plugin_config() |                     if active != plugin.is_active(): | ||||||
|  |                         continue | ||||||
|  |  | ||||||
|                     if config.active != active: |                 if builtin is not None: | ||||||
|  |                     # Filter by 'builtin' status of plugin | ||||||
|  |                     if builtin != plugin.is_builtin: | ||||||
|                         continue |                         continue | ||||||
|  |  | ||||||
|                 result.append(plugin) |                 result.append(plugin) | ||||||
| @@ -403,8 +405,14 @@ class PluginsRegistry: | |||||||
|             # Append reference to plugin |             # Append reference to plugin | ||||||
|             plg.db = plg_db |             plg.db = plg_db | ||||||
|  |  | ||||||
|             # Always activate if testing |             # Check if this is a 'builtin' plugin | ||||||
|             if settings.PLUGIN_TESTING or (plg_db and plg_db.active): |             builtin = plg.check_is_builtin() | ||||||
|  |  | ||||||
|  |             # Determine if this plugin should be loaded: | ||||||
|  |             # - If PLUGIN_TESTING is enabled | ||||||
|  |             # - If this is a 'builtin' plugin | ||||||
|  |             # - If this plugin has been explicitly enabled by the user | ||||||
|  |             if settings.PLUGIN_TESTING or builtin or (plg_db and plg_db.active): | ||||||
|                 # Check if the plugin was blocked -> threw an error; option1: package, option2: file-based |                 # Check if the plugin was blocked -> threw an error; option1: package, option2: file-based | ||||||
|                 if disabled and ((plg.__name__ == disabled) or (plg.__module__ == disabled)): |                 if disabled and ((plg.__name__ == disabled) or (plg.__module__ == disabled)): | ||||||
|                     safe_reference(plugin=plg, key=plg_key, active=False) |                     safe_reference(plugin=plg, key=plg_key, active=False) | ||||||
| @@ -498,10 +506,9 @@ class PluginsRegistry: | |||||||
|             for _key, plugin in plugins: |             for _key, plugin in plugins: | ||||||
|  |  | ||||||
|                 if plugin.mixin_enabled('schedule'): |                 if plugin.mixin_enabled('schedule'): | ||||||
|                     config = plugin.plugin_config() |  | ||||||
|  |  | ||||||
|                     # Only active tasks for plugins which are enabled |                     if plugin.is_active(): | ||||||
|                     if config and config.active: |                         # Only active tasks for plugins which are enabled | ||||||
|                         plugin.register_tasks() |                         plugin.register_tasks() | ||||||
|                         task_keys += plugin.get_task_names() |                         task_keys += plugin.get_task_names() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| """Unit tests for action plugins.""" | """Unit tests for action plugins.""" | ||||||
| 
 | 
 | ||||||
| from InvenTree.helpers import InvenTreeTestCase | from InvenTree.helpers import InvenTreeTestCase | ||||||
| from plugin.builtin.action.simpleactionplugin import SimpleActionPlugin | from plugin.samples.integration.simpleactionplugin import SimpleActionPlugin | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class SimpleActionPluginTests(InvenTreeTestCase): | class SimpleActionPluginTests(InvenTreeTestCase): | ||||||
| @@ -28,25 +28,38 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): | |||||||
|         url = reverse('api-plugin-install') |         url = reverse('api-plugin-install') | ||||||
|  |  | ||||||
|         # valid - Pypi |         # valid - Pypi | ||||||
|         data = self.post(url, { |         data = self.post( | ||||||
|             'confirm': True, |             url, | ||||||
|             'packagename': self.PKG_NAME |             { | ||||||
|         }, expected_code=201).data |                 'confirm': True, | ||||||
|  |                 'packagename': self.PKG_NAME | ||||||
|  |             }, | ||||||
|  |             expected_code=201, | ||||||
|  |         ).data | ||||||
|  |  | ||||||
|         self.assertEqual(data['success'], True) |         self.assertEqual(data['success'], True) | ||||||
|  |  | ||||||
|         # valid - github url |         # valid - github url | ||||||
|         data = self.post(url, { |         data = self.post( | ||||||
|             'confirm': True, |             url, | ||||||
|             'url': self.PKG_URL |             { | ||||||
|         }, expected_code=201).data |                 'confirm': True, | ||||||
|  |                 'url': self.PKG_URL | ||||||
|  |             }, | ||||||
|  |             expected_code=201, | ||||||
|  |         ).data | ||||||
|         self.assertEqual(data['success'], True) |         self.assertEqual(data['success'], True) | ||||||
|  |  | ||||||
|         # valid - github url and packagename |         # valid - github url and packagename | ||||||
|         data = self.post(url, { |         data = self.post( | ||||||
|             'confirm': True, |             url, | ||||||
|             'url': self.PKG_URL, |             { | ||||||
|             'packagename': 'minimal', |                 'confirm': True, | ||||||
|         }, expected_code=201).data |                 'url': self.PKG_URL, | ||||||
|  |                 'packagename': 'minimal', | ||||||
|  |             }, | ||||||
|  |             expected_code=201, | ||||||
|  |         ).data | ||||||
|         self.assertEqual(data['success'], True) |         self.assertEqual(data['success'], True) | ||||||
|  |  | ||||||
|         # invalid tries |         # invalid tries | ||||||
| @@ -57,17 +70,20 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): | |||||||
|         data = self.post(url, { |         data = self.post(url, { | ||||||
|             'confirm': True, |             'confirm': True, | ||||||
|         }, expected_code=400).data |         }, expected_code=400).data | ||||||
|  |  | ||||||
|         self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper()) |         self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper()) | ||||||
|         self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper()) |         self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper()) | ||||||
|  |  | ||||||
|         # not confirmed |         # not confirmed | ||||||
|         self.post(url, { |         self.post(url, { | ||||||
|             'packagename': self.PKG_NAME |             'packagename': self.PKG_NAME | ||||||
|         }, expected_code=400).data |         }, expected_code=400) | ||||||
|  |  | ||||||
|         data = self.post(url, { |         data = self.post(url, { | ||||||
|             'packagename': self.PKG_NAME, |             'packagename': self.PKG_NAME, | ||||||
|             'confirm': False, |             'confirm': False, | ||||||
|         }, expected_code=400).data |         }, expected_code=400).data | ||||||
|  |  | ||||||
|         self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper()) |         self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper()) | ||||||
|  |  | ||||||
|     def test_admin_action(self): |     def test_admin_action(self): | ||||||
|   | |||||||
| @@ -305,6 +305,7 @@ class TestReportTest(ReportTest): | |||||||
|         InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None) |         InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None) | ||||||
|  |  | ||||||
|         response = self.get(url, {'item': item.pk}, expected_code=200) |         response = self.get(url, {'item': item.pk}, expected_code=200) | ||||||
|  |  | ||||||
|         headers = response.headers |         headers = response.headers | ||||||
|         self.assertEqual(headers['Content-Type'], 'application/pdf') |         self.assertEqual(headers['Content-Type'], 'application/pdf') | ||||||
|  |  | ||||||
|   | |||||||
| @@ -31,6 +31,8 @@ | |||||||
| </table> | </table> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | {% plugins_enabled as plug %} | ||||||
|  |  | ||||||
| <div class='panel-heading'> | <div class='panel-heading'> | ||||||
|     <div class='d-flex flex-wrap'> |     <div class='d-flex flex-wrap'> | ||||||
|         <h4>{% trans "Plugins" %}</h4> |         <h4>{% trans "Plugins" %}</h4> | ||||||
| @@ -38,78 +40,46 @@ | |||||||
|         <div class='btn-group' role='group'> |         <div class='btn-group' role='group'> | ||||||
|             {% url 'admin:plugin_pluginconfig_changelist' as url %} |             {% url 'admin:plugin_pluginconfig_changelist' as url %} | ||||||
|             {% include "admin_button.html" with url=url %} |             {% include "admin_button.html" with url=url %} | ||||||
|  |             {% if plug %} | ||||||
|             <button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button> |             <button class="btn btn-success" id="install-plugin" title="{% trans 'Install Plugin' %}"><span class='fas fa-plus-circle'></span> {% trans "Install Plugin" %}</button> | ||||||
|  |             {% endif %} | ||||||
|         </div> |         </div> | ||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | {% if not plug %} | ||||||
|  | <div class='alert alert-warning alert-block'> | ||||||
|  |     {% trans "External plugins are not enabled for this InvenTree installation" %}<br> | ||||||
|  | </div> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
| <div class='table-responsive'> | <div class='table-responsive'> | ||||||
| <table class='table table-striped table-condensed'> | <table class='table table-striped table-condensed'> | ||||||
|     <thead> |     <thead> | ||||||
|         <tr> |         <tr> | ||||||
|             <th>{% trans "Admin" %}</th> |  | ||||||
|             <th>{% trans "Name" %}</th> |             <th>{% trans "Name" %}</th> | ||||||
|  |             <th>{% trans "Key" %}</th> | ||||||
|             <th>{% trans "Author" %}</th> |             <th>{% trans "Author" %}</th> | ||||||
|             <th>{% trans "Date" %}</th> |             <th>{% trans "Date" %}</th> | ||||||
|             <th>{% trans "Version" %}</th> |             <th>{% trans "Version" %}</th> | ||||||
|  |             <th></th> | ||||||
|         </tr> |         </tr> | ||||||
|     </thead> |     </thead> | ||||||
|  |  | ||||||
|     <tbody> |     <tbody> | ||||||
|         {% plugin_list as pl_list %} |         {% plugin_list as pl_list %} | ||||||
|  |         {% if pl_list %} | ||||||
|  |         <tr><td colspan="6"><h6>{% trans 'Active plugins' %}</h6></td></tr> | ||||||
|         {% for plugin_key, plugin in pl_list.items %} |         {% for plugin_key, plugin in pl_list.items %} | ||||||
|         {% mixin_enabled plugin 'urls' as urls %} |             {% include "InvenTree/settings/plugin_details.html" with plugin=plugin plugin_key=plugin_key %} | ||||||
|         {% mixin_enabled plugin 'settings' as settings %} |  | ||||||
|  |  | ||||||
|         <tr> |  | ||||||
|             <td> |  | ||||||
|                 {% if user.is_staff and perms.plugin.change_pluginconfig %} |  | ||||||
|                 {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} |  | ||||||
|                 {% include "admin_button.html" with url=url %} |  | ||||||
|                 {% endif %} |  | ||||||
|             </td> |  | ||||||
|             <td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span> |  | ||||||
|                 {% define plugin.registered_mixins as mixin_list %} |  | ||||||
|  |  | ||||||
|                 {% if plugin.is_sample %} |  | ||||||
|                 <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar"> |  | ||||||
|                     <span class='badge bg-info rounded-pill badge-right'>{% trans "Sample" %}</span> |  | ||||||
|                 </a> |  | ||||||
|                 {% endif %} |  | ||||||
|  |  | ||||||
|                 {% if mixin_list %} |  | ||||||
|                 {% for mixin in mixin_list %} |  | ||||||
|                 <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar"> |  | ||||||
|                     <span class='badge bg-dark badge-right rounded-pill'>{{ mixin.human_name }}</span> |  | ||||||
|                 </a> |  | ||||||
|                 {% endfor %} |  | ||||||
|                 {% endif %} |  | ||||||
|  |  | ||||||
|                 {% if plugin.website %} |  | ||||||
|                 <a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a> |  | ||||||
|                 {% endif %} |  | ||||||
|             </td> |  | ||||||
|             <td>{{ plugin.author }}</td> |  | ||||||
|             <td>{% render_date plugin.pub_date %}</td> |  | ||||||
|             <td>{% if plugin.version %}{{ plugin.version }}{% endif %}</td> |  | ||||||
|         </tr> |  | ||||||
|         {% endfor %} |         {% endfor %} | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|         {% inactive_plugin_list as in_pl_list %} |         {% inactive_plugin_list as in_pl_list %} | ||||||
|         {% if in_pl_list %} |         {% if in_pl_list %} | ||||||
|         <tr><td colspan="5"></td></tr> |         <tr><td colspan="6"><h6>{% trans 'Inactive plugins' %}</h6></td></tr> | ||||||
|         <tr><td colspan="5"><h6>{% trans 'Inactive plugins' %}</h6></td></tr> |  | ||||||
|         {% for plugin_key, plugin in in_pl_list.items %} |         {% for plugin_key, plugin in in_pl_list.items %} | ||||||
|         <tr> |             {% include "InvenTree/settings/plugin_details.html" with plugin=plugin plugin_key=plugin_key %} | ||||||
|             <td> |  | ||||||
|                 {% if user.is_staff and perms.plugin.change_pluginconfig %} |  | ||||||
|                 {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} |  | ||||||
|                 {% include "admin_button.html" with url=url %} |  | ||||||
|                 {% endif %} |  | ||||||
|             </td> |  | ||||||
|             <td>{{plugin.name}}<span class="text-muted"> - {{plugin.key}}</span></td> |  | ||||||
|             <td colspan="3"></td> |  | ||||||
|         </tr> |  | ||||||
|         {% endfor %} |         {% endfor %} | ||||||
|         {% endif %} |         {% endif %} | ||||||
|     </tbody> |     </tbody> | ||||||
|   | |||||||
							
								
								
									
										75
									
								
								InvenTree/templates/InvenTree/settings/plugin_details.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								InvenTree/templates/InvenTree/settings/plugin_details.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | {% load inventree_extras %} | ||||||
|  | {% load i18n %} | ||||||
|  |  | ||||||
|  | <tr> | ||||||
|  |     <td> | ||||||
|  |         {% if plugin.is_active %} | ||||||
|  |         <span class='fas fa-check-circle icon-green'></span> | ||||||
|  |         {% else %} | ||||||
|  |         <span class='fas fa-times-circle icon-red'></span> | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|  |         {% if plugin.human_name %} | ||||||
|  |         {{ plugin.human_name }} | ||||||
|  |         {% elif plugin.title %} | ||||||
|  |         {{ plugin.title }} | ||||||
|  |         {% elif plugin.name %} | ||||||
|  |         {{ plugin.name }} | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|  |         {% define plugin.registered_mixins as mixin_list %} | ||||||
|  |  | ||||||
|  |         {% if mixin_list %} | ||||||
|  |         {% for mixin in mixin_list %} | ||||||
|  |         <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar"> | ||||||
|  |             <span class='badge bg-dark badge-right rounded-pill'>{{ mixin.human_name }}</span> | ||||||
|  |         </a> | ||||||
|  |         {% endfor %} | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|  |         {% if plugin.is_builtin %} | ||||||
|  |         <a class='sidebar-selector' id='select-plugin-{{ plugin_key }}' data-bs-parent='#sidebar'> | ||||||
|  |             <span class='badge bg-success rounded-pill badge-right'>{% trans "Builtin" %}</span> | ||||||
|  |         </a> | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|  |         {% if plugin.is_sample %} | ||||||
|  |         <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar"> | ||||||
|  |             <span class='badge bg-info rounded-pill badge-right'>{% trans "Sample" %}</span> | ||||||
|  |         </a> | ||||||
|  |         {% endif %} | ||||||
|  |  | ||||||
|  |         {% if plugin.website %} | ||||||
|  |         <a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a> | ||||||
|  |         {% endif %} | ||||||
|  |     </td> | ||||||
|  |     <td>{{ plugin_key }}</td> | ||||||
|  |     {% trans "Unvailable" as no_info %} | ||||||
|  |     <td> | ||||||
|  |         {% if plugin.author %} | ||||||
|  |         {{ plugin.author }} | ||||||
|  |         {% else %} | ||||||
|  |         <em>{{ no_info }}</em> | ||||||
|  |         {% endif %} | ||||||
|  |     </td> | ||||||
|  |     <td> | ||||||
|  |         {% if plugin.pub_date %} | ||||||
|  |         {% render_date plugin.pub_date %} | ||||||
|  |         {% else %} | ||||||
|  |         <em>{{ no_info }}</em> | ||||||
|  |         {% endif %} | ||||||
|  |     </td> | ||||||
|  |     <td> | ||||||
|  |         {% if plugin.version %} | ||||||
|  |         {{ plugin.version }} | ||||||
|  |         {% else %} | ||||||
|  |         <em>{{ no_info }}</em> | ||||||
|  |         {% endif %} | ||||||
|  |     </td> | ||||||
|  |     <td> | ||||||
|  |         {% if user.is_staff and perms.plugin.change_pluginconfig %} | ||||||
|  |         {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} | ||||||
|  |         {% include "admin_button.html" with url=url %} | ||||||
|  |         {% endif %} | ||||||
|  |     </td> | ||||||
|  | </tr> | ||||||
| @@ -23,16 +23,16 @@ | |||||||
|                 <td>{% trans "Name" %}</td> |                 <td>{% trans "Name" %}</td> | ||||||
|                 <td>{{ plugin.human_name }}{% include "clip.html" %}</td> |                 <td>{{ plugin.human_name }}{% include "clip.html" %}</td> | ||||||
|             </tr> |             </tr> | ||||||
|             <tr> |  | ||||||
|                 <td><span class='fas fa-user'></span></span></td> |  | ||||||
|                 <td>{% trans "Author" %}</td> |  | ||||||
|                 <td>{{ plugin.author }}{% include "clip.html" %}</td> |  | ||||||
|             </tr> |  | ||||||
|             <tr> |             <tr> | ||||||
|                 <td></td> |                 <td></td> | ||||||
|                 <td>{% trans "Description" %}</td> |                 <td>{% trans "Description" %}</td> | ||||||
|                 <td>{{ plugin.description }}{% include "clip.html" %}</td> |                 <td>{{ plugin.description }}{% include "clip.html" %}</td> | ||||||
|             </tr> |             </tr> | ||||||
|  |             <tr> | ||||||
|  |                 <td><span class='fas fa-user'></span></span></td> | ||||||
|  |                 <td>{% trans "Author" %}</td> | ||||||
|  |                 <td>{{ plugin.author }}{% include "clip.html" %}</td> | ||||||
|  |             </tr> | ||||||
|             <tr> |             <tr> | ||||||
|                 <td><span class='fas fa-calendar-alt'></span></td> |                 <td><span class='fas fa-calendar-alt'></span></td> | ||||||
|                 <td>{% trans "Date" %}</td> |                 <td>{% trans "Date" %}</td> | ||||||
| @@ -94,7 +94,14 @@ | |||||||
|                 <td>{% trans "Installation path" %}</td> |                 <td>{% trans "Installation path" %}</td> | ||||||
|                 <td>{{ plugin.package_path }}</td> |                 <td>{{ plugin.package_path }}</td> | ||||||
|             </tr> |             </tr> | ||||||
|             {% if plugin.is_package == False %} |             {% if plugin.is_package %} | ||||||
|  |             {% elif plugin.is_builtin %} | ||||||
|  |             <tr> | ||||||
|  |                 <td><span class='fas fa-check-circle icon-green'></span></td> | ||||||
|  |                 <td>{% trans "Builtin" %}</td> | ||||||
|  |                 <td>{% trans "This is a builtin plugin which cannot be disabled" %}</td> | ||||||
|  |             </tr> | ||||||
|  |             {% else %} | ||||||
|             <tr> |             <tr> | ||||||
|                 <td><span class='fas fa-user'></span></td> |                 <td><span class='fas fa-user'></span></td> | ||||||
|                 <td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td> |                 <td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td> | ||||||
|   | |||||||
| @@ -42,8 +42,6 @@ | |||||||
| {% include "InvenTree/settings/po.html" %} | {% include "InvenTree/settings/po.html" %} | ||||||
| {% include "InvenTree/settings/so.html" %} | {% include "InvenTree/settings/so.html" %} | ||||||
|  |  | ||||||
| {% plugins_enabled as plug %} |  | ||||||
| {% if plug %} |  | ||||||
| {% include "InvenTree/settings/plugin.html" %} | {% include "InvenTree/settings/plugin.html" %} | ||||||
| {% plugin_list as pl_list %} | {% plugin_list as pl_list %} | ||||||
| {% for plugin_key, plugin in pl_list.items %} | {% for plugin_key, plugin in pl_list.items %} | ||||||
| @@ -51,7 +49,6 @@ | |||||||
|         {% include "InvenTree/settings/plugin_settings.html" %} |         {% include "InvenTree/settings/plugin_settings.html" %} | ||||||
|     {% endif %} |     {% endif %} | ||||||
| {% endfor %} | {% endfor %} | ||||||
| {% endif %} |  | ||||||
|  |  | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -51,8 +51,7 @@ | |||||||
| {% trans "Sales Orders" as text %} | {% trans "Sales Orders" as text %} | ||||||
| {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} | {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} | ||||||
|  |  | ||||||
| {% plugins_enabled as plug %} |  | ||||||
| {% if plug %} |  | ||||||
| {% trans "Plugin Settings" as text %} | {% trans "Plugin Settings" as text %} | ||||||
| {% include "sidebar_header.html" with text=text %} | {% include "sidebar_header.html" with text=text %} | ||||||
| {% trans "Plugins" as text %} | {% trans "Plugins" as text %} | ||||||
| @@ -64,6 +63,5 @@ | |||||||
| {% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %} | {% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %} | ||||||
| {% endif %} | {% endif %} | ||||||
| {% endfor %} | {% endfor %} | ||||||
| {% endif %} |  | ||||||
|  |  | ||||||
| {% endif %} | {% endif %} | ||||||
|   | |||||||
| @@ -1,4 +1,12 @@ | |||||||
|  | {% load inventree_extras %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
| <button id='admin-button' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary admin-button' url='{{ url }}'> |  | ||||||
|     <span class='fas fa-user-shield'></span> | {% inventree_customize 'hide_admin_link' as hidden %} | ||||||
| </button> |  | ||||||
|  | {% if not hidden and user.is_staff %} | ||||||
|  | <a href='{{ url }}'> | ||||||
|  |     <button id='admin-button' href='{{ url }}' title='{% trans "View in administration panel" %}' type='button' class='btn btn-primary admin-button'> | ||||||
|  |         <span class='fas fa-user-shield'></span> | ||||||
|  |     </button> | ||||||
|  | </a> | ||||||
|  | {% endif %} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user