mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-01 17:41:33 +00:00
[plugin] Mandatory plugins (#10094)
* Add setting for "mandatory" plugins * Add 'is_active' method to PluginConfig model * Check against plugin config object by priority * Prevent plugin from reporting its own 'active' status * Refactor get_plugin_class for LabelPrint endpoint * Fix typo * Mark internal plugin methods as "final" - Prevent plugins from overriding them * Enhanced checks for bad actor plugins * Enhanced unit test for plugin install via API * Playwright tests for plugin errors * Test that builtin mandatory plugins are always activated * Force mandatory plugins to be marked as active on load * API unit tests * Unit testing for plugin filtering * Updated playwright tests - Force one extra plugin to be mandatory in configuration * Adjust unit tests * Updated docs * Tweak unit test * Another unit test fix * Fix with_mixin - Checking active status first is expensive... * Make with_mixin call much more efficient - Pre-load the PluginConfig objects - Additional unit tests - Ensure fixed query count * Fix the 'is_package' method for PluginConfig * Tweak unit test * Make api_info endpoint more efficient - with_mixin is now very quick * Run just single test * Disable CI test * Revert changes to CI pipeline * Fix typo * Debug for test * Style fix * Additional checks * Ensure reload * Ensure plugin registry is ready before running unit tests * Fix typo * Add debug statements * Additional debug output * Debug logging for MySQL * Ensure config objects are created? * Ensure plugin registry is reloaded before running tests * Remove intentional failure * Reset debug level * Fix CI pipeline * Fix * Fix test mixins * Fix test class * Further updates * Adjust info view * Test refactoring * Fix recursion issue in machine registry * Force cache behavior * Reduce API query limits in testing * Handle potential error case in with_mixin * remove custom query time code * Prevent override of is_mandatory() * Prevent unnecessary reloads * Tweak unit tests * Tweak mandatory active save * Tweak unit test * Enhanced unit testing * Exclude lines from coverage * (final)? cleanup * Prevent recursive reloads --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -295,7 +295,6 @@ class InfoView(APIView):
|
||||
'worker_pending_tasks': self.worker_pending_tasks(),
|
||||
'plugins_enabled': settings.PLUGINS_ENABLED,
|
||||
'plugins_install_disabled': settings.PLUGINS_INSTALL_DISABLED,
|
||||
'active_plugins': plugins_info(),
|
||||
'email_configured': is_email_configured(),
|
||||
'debug_mode': settings.DEBUG,
|
||||
'docker_mode': settings.DOCKER,
|
||||
@@ -307,6 +306,7 @@ class InfoView(APIView):
|
||||
'navbar_message': helpers.getCustomOption('navbar_message'),
|
||||
},
|
||||
# Following fields are only available to staff users
|
||||
'active_plugins': plugins_info() if is_staff else None,
|
||||
'system_health': check_system_health() if is_staff else None,
|
||||
'database': InvenTree.version.inventreeDatabase() if is_staff else None,
|
||||
'platform': InvenTree.version.inventreePlatform() if is_staff else None,
|
||||
|
@@ -1095,16 +1095,17 @@ def pui_url(subpath: str) -> str:
|
||||
|
||||
def plugins_info(*args, **kwargs):
|
||||
"""Return information about activated plugins."""
|
||||
from plugin import PluginMixinEnum
|
||||
from plugin.registry import registry
|
||||
|
||||
# Check if plugins are even enabled
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
return False
|
||||
|
||||
# Fetch plugins
|
||||
plug_list = [plg for plg in registry.plugins.values() if plg.plugin_config().active]
|
||||
# Fetch active plugins
|
||||
plugins = registry.with_mixin(PluginMixinEnum.BASE)
|
||||
|
||||
# Format list
|
||||
return [
|
||||
{'name': plg.name, 'slug': plg.slug, 'version': plg.version}
|
||||
for plg in plug_list
|
||||
{'name': plg.name, 'slug': plg.slug, 'version': plg.version} for plg in plugins
|
||||
]
|
||||
|
@@ -192,6 +192,10 @@ PLUGINS_ENABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGINS_ENABLED', 'plugins_enabled', False
|
||||
)
|
||||
|
||||
PLUGINS_MANDATORY = get_setting(
|
||||
'INVENTREE_PLUGINS_MANDATORY', 'plugins_mandatory', typecast=list, default_value=[]
|
||||
)
|
||||
|
||||
PLUGINS_INSTALL_DISABLED = get_boolean_setting(
|
||||
'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False
|
||||
)
|
||||
@@ -211,6 +215,7 @@ PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
PLUGIN_TESTING_EVENTS_ASYNC = False # Flag if events are tested asynchronously
|
||||
PLUGIN_TESTING_RELOAD = False # Flag if plugin reloading is in testing (check_reload)
|
||||
|
||||
# Plugin development settings
|
||||
PLUGIN_DEV_SLUG = (
|
||||
get_setting('INVENTREE_PLUGIN_DEV_SLUG', 'plugin_dev.slug') if DEBUG else None
|
||||
)
|
||||
|
@@ -580,9 +580,15 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
|
||||
def test_info_view(self):
|
||||
"""Test that we can read the 'info-view' endpoint."""
|
||||
from plugin import PluginMixinEnum
|
||||
from plugin.models import PluginConfig
|
||||
from plugin.registry import registry
|
||||
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
url = reverse('api-inventree-info')
|
||||
|
||||
response = self.get(url, max_query_count=275, expected_code=200)
|
||||
response = self.get(url, max_query_count=20, expected_code=200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIn('server', data)
|
||||
@@ -592,15 +598,41 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
self.assertEqual('InvenTree', data['server'])
|
||||
|
||||
# Test with token
|
||||
token = self.get(url=reverse('api-token'), max_query_count=275).data['token']
|
||||
token = self.get(url=reverse('api-token'), max_query_count=20).data['token']
|
||||
self.client.logout()
|
||||
|
||||
# Anon
|
||||
response = self.get(url, max_query_count=275)
|
||||
self.assertEqual(response.json()['database'], None)
|
||||
response = self.get(url, max_query_count=20)
|
||||
data = response.json()
|
||||
self.assertEqual(data['database'], None)
|
||||
|
||||
# No active plugin info for anon user
|
||||
self.assertIsNone(data.get('active_plugins'))
|
||||
|
||||
# Staff
|
||||
response = self.get(
|
||||
url, headers={'Authorization': f'Token {token}'}, max_query_count=275
|
||||
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
|
||||
)
|
||||
self.assertGreater(len(response.json()['database']), 4)
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Check for active plugin list
|
||||
self.assertIn('active_plugins', data)
|
||||
plugins = data['active_plugins']
|
||||
|
||||
# Check that all active plugins are listed
|
||||
N = len(plugins)
|
||||
self.assertGreater(N, 0, 'No active plugins found')
|
||||
self.assertLess(N, PluginConfig.objects.count(), 'Too many plugins found')
|
||||
self.assertEqual(
|
||||
N,
|
||||
len(registry.with_mixin(PluginMixinEnum.BASE, active=True)),
|
||||
'Incorrect number of active plugins found',
|
||||
)
|
||||
|
||||
keys = [plugin['slug'] for plugin in plugins]
|
||||
|
||||
self.assertIn('bom-exporter', keys)
|
||||
self.assertIn('inventree-ui-notification', keys)
|
||||
self.assertIn('inventreelabel', keys)
|
||||
|
@@ -15,6 +15,7 @@ from error_report.models import Error
|
||||
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||
from InvenTree.unit_test import PluginRegistryMixin
|
||||
|
||||
threshold = timezone.now() - timedelta(days=30)
|
||||
threshold_low = threshold - timedelta(days=1)
|
||||
@@ -55,7 +56,7 @@ def get_result():
|
||||
return 'abc'
|
||||
|
||||
|
||||
class InvenTreeTaskTests(TestCase):
|
||||
class InvenTreeTaskTests(PluginRegistryMixin, TestCase):
|
||||
"""Unit tests for tasks."""
|
||||
|
||||
def test_offloading(self):
|
||||
|
@@ -320,12 +320,8 @@ class ExchangeRateMixin:
|
||||
Rate.objects.bulk_create(items)
|
||||
|
||||
|
||||
class InvenTreeTestCase(ExchangeRateMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup build in."""
|
||||
|
||||
|
||||
class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
"""Base class for running InvenTree API tests."""
|
||||
class TestQueryMixin:
|
||||
"""Mixin class for testing query counts."""
|
||||
|
||||
# Default query count threshold value
|
||||
# TODO: This value should be reduced
|
||||
@@ -375,18 +371,48 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
|
||||
self.assertLess(n, value, msg=msg)
|
||||
|
||||
|
||||
class PluginRegistryMixin:
|
||||
"""Mixin to ensure that the plugin registry is ready for tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Setup for API tests.
|
||||
"""Ensure that the plugin registry is ready for tests."""
|
||||
from time import sleep
|
||||
|
||||
- Ensure that all global settings are assigned default values.
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
from plugin.registry import registry
|
||||
|
||||
while not registry.is_ready:
|
||||
print('Waiting for plugin registry to be ready...')
|
||||
sleep(0.1)
|
||||
|
||||
assert registry.is_ready, 'Plugin registry is not ready'
|
||||
|
||||
InvenTreeSetting.build_default_values()
|
||||
|
||||
super().setUpTestData()
|
||||
|
||||
def ensurePluginsLoaded(self, force: bool = False):
|
||||
"""Helper function to ensure that plugins are loaded."""
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
if force or PluginConfig.objects.count() == 0:
|
||||
# Reload the plugin registry at this point to ensure all PluginConfig objects are created
|
||||
# This is because the django test system may have re-initialized the database (to an empty state)
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
assert PluginConfig.objects.count() > 0, 'No plugins are installed'
|
||||
|
||||
|
||||
class InvenTreeTestCase(ExchangeRateMixin, PluginRegistryMixin, UserMixin, TestCase):
|
||||
"""Testcase with user setup build in."""
|
||||
|
||||
|
||||
class InvenTreeAPITestCase(
|
||||
ExchangeRateMixin, PluginRegistryMixin, TestQueryMixin, UserMixin, APITestCase
|
||||
):
|
||||
"""Base class for running InvenTree API tests."""
|
||||
|
||||
def check_response(self, url, response, expected_code=None):
|
||||
"""Debug output for an unexpected response."""
|
||||
# Check that the response returned the expected status code
|
||||
@@ -472,15 +498,15 @@ class InvenTreeAPITestCase(ExchangeRateMixin, UserMixin, APITestCase):
|
||||
url, self.client.delete, expected_code=expected_code, **kwargs
|
||||
)
|
||||
|
||||
def patch(self, url, data, expected_code=200, **kwargs):
|
||||
def patch(self, url, data=None, expected_code=200, **kwargs):
|
||||
"""Issue a PATCH request."""
|
||||
kwargs['data'] = data
|
||||
kwargs['data'] = data or {}
|
||||
|
||||
return self.query(url, self.client.patch, expected_code=expected_code, **kwargs)
|
||||
|
||||
def put(self, url, data, expected_code=200, **kwargs):
|
||||
def put(self, url, data=None, expected_code=200, **kwargs):
|
||||
"""Issue a PUT request."""
|
||||
kwargs['data'] = data
|
||||
kwargs['data'] = data or {}
|
||||
|
||||
return self.query(url, self.client.put, expected_code=expected_code, **kwargs)
|
||||
|
||||
|
@@ -7,7 +7,6 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Sum
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -20,7 +19,11 @@ from build.models import Build, BuildItem, BuildLine, generate_next_build_refere
|
||||
from build.status_codes import BuildStatus
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, findOffloadedEvent
|
||||
from InvenTree.unit_test import (
|
||||
InvenTreeAPITestCase,
|
||||
InvenTreeTestCase,
|
||||
findOffloadedEvent,
|
||||
)
|
||||
from order.models import PurchaseOrder, PurchaseOrderLineItem
|
||||
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
|
||||
from stock.models import StockItem, StockItemTestResult, StockLocation
|
||||
@@ -29,7 +32,7 @@ from users.models import Owner
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
class BuildTestBase(TestCase):
|
||||
class BuildTestBase(InvenTreeTestCase):
|
||||
"""Run some tests to ensure that the Build model is working properly."""
|
||||
|
||||
fixtures = ['users']
|
||||
@@ -619,6 +622,8 @@ class BuildTest(BuildTestBase):
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test sending of notifications when a build order is overdue."""
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
self.build.target_date = datetime.now().date() - timedelta(days=1)
|
||||
self.build.save()
|
||||
|
||||
|
@@ -471,7 +471,7 @@ class SettingsTest(InvenTreeTestCase):
|
||||
self.assertIsNone(cache.get(cache_key))
|
||||
|
||||
# First request should set cache
|
||||
val = InvenTreeSetting.get_setting(key)
|
||||
val = InvenTreeSetting.get_setting(key, cache=True)
|
||||
self.assertEqual(cache.get(cache_key).value, val)
|
||||
|
||||
for val in ['A', '{{ part.IPN }}', 'C']:
|
||||
|
@@ -153,12 +153,12 @@ class MachineRegistry(
|
||||
if machine.active:
|
||||
machine.initialize()
|
||||
|
||||
self._update_registry_hash()
|
||||
logger.info('Initialized %s machines', len(self.machines.keys()))
|
||||
else:
|
||||
self._hash = None # reset hash to force reload hash
|
||||
logger.info('Loaded %s machines', len(self.machines.keys()))
|
||||
|
||||
self._update_registry_hash()
|
||||
|
||||
def reload_machines(self):
|
||||
"""Reload all machines from the database."""
|
||||
self.machines = {}
|
||||
|
@@ -198,8 +198,9 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
|
||||
# Create a machine
|
||||
response = self.post(
|
||||
reverse('api-machine-list'), machine_data, max_query_count=400
|
||||
reverse('api-machine-list'), machine_data, max_query_count=150
|
||||
)
|
||||
|
||||
self.assertEqual(response.data, {**response.data, **machine_data})
|
||||
pk = response.data['pk']
|
||||
|
||||
@@ -233,16 +234,13 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
|
||||
def test_machine_detail_settings(self):
|
||||
"""Test machine detail settings API endpoint."""
|
||||
# TODO: Investigate why these tests need a higher query limit
|
||||
QUERY_LIMIT = 300
|
||||
|
||||
machine_setting_url = reverse(
|
||||
'api-machine-settings-detail',
|
||||
kwargs={'pk': self.placeholder_uuid, 'config_type': 'M', 'key': 'LOCATION'},
|
||||
)
|
||||
|
||||
# Test machine settings for non-existent machine
|
||||
self.get(machine_setting_url, expected_code=404, max_query_count=QUERY_LIMIT)
|
||||
self.get(machine_setting_url, expected_code=404)
|
||||
|
||||
# Create a machine
|
||||
machine = MachineConfig.objects.create(
|
||||
@@ -262,22 +260,18 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
# Get settings
|
||||
response = self.get(machine_setting_url, max_query_count=QUERY_LIMIT)
|
||||
response = self.get(machine_setting_url)
|
||||
self.assertEqual(response.data['value'], '')
|
||||
|
||||
response = self.get(driver_setting_url, max_query_count=QUERY_LIMIT)
|
||||
response = self.get(driver_setting_url)
|
||||
self.assertEqual(response.data['value'], '')
|
||||
|
||||
# Update machine setting
|
||||
location = StockLocation.objects.create(name='Test Location')
|
||||
response = self.patch(
|
||||
machine_setting_url,
|
||||
{'value': str(location.pk)},
|
||||
max_query_count=QUERY_LIMIT,
|
||||
)
|
||||
response = self.patch(machine_setting_url, {'value': str(location.pk)})
|
||||
self.assertEqual(response.data['value'], str(location.pk))
|
||||
|
||||
response = self.get(machine_setting_url, max_query_count=QUERY_LIMIT)
|
||||
response = self.get(machine_setting_url)
|
||||
self.assertEqual(response.data['value'], str(location.pk))
|
||||
|
||||
# Update driver setting
|
||||
@@ -289,7 +283,7 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
|
||||
# Get list of all settings for a machine
|
||||
settings_url = reverse('api-machine-settings', kwargs={'pk': machine.pk})
|
||||
response = self.get(settings_url, max_query_count=QUERY_LIMIT)
|
||||
response = self.get(settings_url)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(
|
||||
[('M', 'LOCATION'), ('D', 'TEST_SETTING')],
|
||||
|
@@ -5,13 +5,12 @@ from datetime import datetime, timedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
import order.tasks
|
||||
from common.models import InvenTreeSetting, NotificationMessage
|
||||
from company.models import Company
|
||||
from InvenTree import status_codes as status
|
||||
from InvenTree.unit_test import addUserPermission
|
||||
from InvenTree.unit_test import InvenTreeTestCase, addUserPermission
|
||||
from order.models import (
|
||||
SalesOrder,
|
||||
SalesOrderAllocation,
|
||||
@@ -24,7 +23,7 @@ from stock.models import StockItem
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
class SalesOrderTest(TestCase):
|
||||
class SalesOrderTest(InvenTreeTestCase):
|
||||
"""Run tests to ensure that the SalesOrder model is working correctly."""
|
||||
|
||||
fixtures = ['users']
|
||||
@@ -319,6 +318,8 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test overdue sales order notification."""
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
user = get_user_model().objects.get(pk=3)
|
||||
|
||||
addUserPermission(user, 'order', 'salesorder', 'view')
|
||||
|
@@ -14,7 +14,11 @@ import common.models
|
||||
import order.tasks
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.unit_test import ExchangeRateMixin, addUserPermission
|
||||
from InvenTree.unit_test import (
|
||||
ExchangeRateMixin,
|
||||
PluginRegistryMixin,
|
||||
addUserPermission,
|
||||
)
|
||||
from order.status_codes import PurchaseOrderStatus
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
@@ -23,7 +27,7 @@ from users.models import Owner
|
||||
from .models import PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem
|
||||
|
||||
|
||||
class OrderTest(TestCase, ExchangeRateMixin):
|
||||
class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
|
||||
"""Tests to ensure that the order models are functioning correctly."""
|
||||
|
||||
fixtures = [
|
||||
@@ -421,6 +425,8 @@ class OrderTest(TestCase, ExchangeRateMixin):
|
||||
|
||||
Ensure that a notification is sent when a PurchaseOrder becomes overdue
|
||||
"""
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
po = PurchaseOrder.objects.get(pk=1)
|
||||
|
||||
# Ensure that the right users have the right permissions
|
||||
|
@@ -94,10 +94,14 @@ class PluginFilter(rest_filters.FilterSet):
|
||||
|
||||
def filter_mandatory(self, queryset, name, value):
|
||||
"""Filter by 'mandatory' flag."""
|
||||
from django.conf import settings
|
||||
|
||||
mandatory_keys = [*registry.MANDATORY_PLUGINS, *settings.PLUGINS_MANDATORY]
|
||||
|
||||
if str2bool(value):
|
||||
return queryset.filter(key__in=registry.MANDATORY_PLUGINS)
|
||||
return queryset.filter(key__in=mandatory_keys)
|
||||
else:
|
||||
return queryset.exclude(key__in=registry.MANDATORY_PLUGINS)
|
||||
return queryset.exclude(key__in=mandatory_keys)
|
||||
|
||||
sample = rest_filters.BooleanFilter(
|
||||
field_name='sample', label=_('Sample'), method='filter_sample'
|
||||
|
@@ -68,8 +68,9 @@ class ScheduleMixin:
|
||||
if settings.PLUGIN_TESTING or get_global_setting('ENABLE_PLUGINS_SCHEDULE'):
|
||||
for _key, plugin in plugins:
|
||||
if (
|
||||
plugin.mixin_enabled(PluginMixinEnum.SCHEDULE)
|
||||
plugin
|
||||
and plugin.is_active()
|
||||
and plugin.mixin_enabled(PluginMixinEnum.SCHEDULE)
|
||||
):
|
||||
# Only active tasks for plugins which are enabled
|
||||
plugin.register_tasks()
|
||||
|
@@ -60,23 +60,37 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
||||
|
||||
def test_api(self):
|
||||
"""Test that we can filter the API endpoint by mixin."""
|
||||
self.ensurePluginsLoaded(force=True)
|
||||
|
||||
url = reverse('api-plugin-list')
|
||||
|
||||
# Try POST (disallowed)
|
||||
response = self.client.post(url, {})
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
response = self.client.get(url, {'mixin': 'labels', 'active': True})
|
||||
response = self.client.get(
|
||||
url, {'mixin': PluginMixinEnum.LABELS, 'active': True}
|
||||
)
|
||||
|
||||
# No results matching this query!
|
||||
self.assertEqual(len(response.data), 0)
|
||||
# Two mandatory label printing plugins
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# What about inactive?
|
||||
response = self.client.get(url, {'mixin': 'labels', 'active': False})
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
# One builtin, non-mandatory label printing plugin "inventreelabelsheet"
|
||||
# One sample plugin, "samplelabelprinter"
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(response.data[0]['key'], 'inventreelabelsheet')
|
||||
self.assertEqual(response.data[1]['key'], 'samplelabelprinter')
|
||||
|
||||
with self.assertWarnsMessage(
|
||||
UserWarning,
|
||||
'A plugin registry reload was triggered for plugin samplelabelprinter',
|
||||
):
|
||||
# Activate the sample label printing plugin
|
||||
self.do_activate_plugin()
|
||||
|
||||
self.do_activate_plugin()
|
||||
# Should be available via the API now
|
||||
response = self.client.get(url, {'mixin': 'labels', 'active': True})
|
||||
|
||||
@@ -141,7 +155,7 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
||||
{'template': template.pk, 'plugin': config.key, 'items': [1, 2, 3]},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn('Plugin is not active', str(response.data['plugin']))
|
||||
self.assertIn('Plugin not found', str(response.data['plugin']))
|
||||
|
||||
# Active plugin
|
||||
self.do_activate_plugin()
|
||||
|
22
src/backend/InvenTree/plugin/broken/bad_actor.py
Normal file
22
src/backend/InvenTree/plugin/broken/bad_actor.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Attempt to create a bad actor plugin that overrides internal methods."""
|
||||
|
||||
from plugin import InvenTreePlugin
|
||||
|
||||
|
||||
class BadActorPlugin(InvenTreePlugin):
|
||||
"""A plugin that attempts to override internal methods."""
|
||||
|
||||
SLUG = 'bad_actor'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the plugin."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.add_mixin('settings', 'has_settings', __class__)
|
||||
|
||||
def plugin_slug(self) -> str:
|
||||
"""Return the slug of this plugin."""
|
||||
return 'bad_actor'
|
||||
|
||||
def plugin_name(self) -> str:
|
||||
"""Return the name of this plugin."""
|
||||
return 'Bad Actor Plugin'
|
@@ -12,6 +12,11 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock', 'company', 'supplier_part']
|
||||
|
||||
def setUp(self):
|
||||
"""Set up the test case."""
|
||||
super().setUp()
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
def test_assign_errors(self):
|
||||
"""Test error cases for assignment action."""
|
||||
|
||||
|
@@ -341,6 +341,19 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T
|
||||
_('Plugin cannot be uninstalled as it is currently active')
|
||||
)
|
||||
|
||||
if cfg.is_mandatory():
|
||||
raise ValidationError(_('Plugin cannot be uninstalled as it is mandatory'))
|
||||
|
||||
if cfg.is_sample():
|
||||
raise ValidationError(
|
||||
_('Plugin cannot be uninstalled as it is a sample plugin')
|
||||
)
|
||||
|
||||
if cfg.is_builtin():
|
||||
raise ValidationError(
|
||||
_('Plugin cannot be uninstalled as it is a built-in plugin')
|
||||
)
|
||||
|
||||
if not cfg.is_installed():
|
||||
raise ValidationError(_('Plugin is not installed'))
|
||||
|
||||
|
@@ -145,17 +145,24 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
"""Extend save method to reload plugins if the 'active' status changes."""
|
||||
no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
|
||||
super().save(force_insert, force_update, *args, **kwargs)
|
||||
mandatory = self.is_mandatory()
|
||||
|
||||
if self.is_mandatory():
|
||||
if mandatory:
|
||||
# Force active if mandatory plugin
|
||||
self.active = True
|
||||
|
||||
if not no_reload and self.active != self.__org_active:
|
||||
super().save(force_insert, force_update, *args, **kwargs)
|
||||
|
||||
if not no_reload and self.active != self.__org_active and not mandatory:
|
||||
if settings.PLUGIN_TESTING:
|
||||
warnings.warn('A plugin registry reload was triggered', stacklevel=2)
|
||||
warnings.warn(
|
||||
f'A plugin registry reload was triggered for plugin {self.key}',
|
||||
stacklevel=2,
|
||||
)
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
self.__org_active = self.active
|
||||
|
||||
@admin.display(boolean=True, description=_('Installed'))
|
||||
def is_installed(self) -> bool:
|
||||
"""Simple check to determine if this plugin is installed.
|
||||
@@ -184,15 +191,32 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
@admin.display(boolean=True, description=_('Mandatory Plugin'))
|
||||
def is_mandatory(self) -> bool:
|
||||
"""Return True if this plugin is mandatory."""
|
||||
# List of run-time configured mandatory plugins
|
||||
if settings.PLUGINS_MANDATORY:
|
||||
if self.key in settings.PLUGINS_MANDATORY:
|
||||
return True
|
||||
|
||||
# Hard-coded list of mandatory "builtin" plugins
|
||||
return self.key in registry.MANDATORY_PLUGINS
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return True if this plugin is active.
|
||||
|
||||
Note that 'mandatory' plugins are always considered 'active',
|
||||
"""
|
||||
return self.is_mandatory() or self.active
|
||||
|
||||
@admin.display(boolean=True, description=_('Package Plugin'))
|
||||
def is_package(self) -> bool:
|
||||
"""Return True if this is a 'package' plugin."""
|
||||
if self.package_name:
|
||||
return True
|
||||
|
||||
if not self.plugin:
|
||||
return False
|
||||
|
||||
return getattr(self.plugin, 'is_package', False)
|
||||
pkg_name = getattr(self.plugin, 'package_name', None)
|
||||
return pkg_name is not None
|
||||
|
||||
@property
|
||||
def admin_source(self) -> str:
|
||||
|
@@ -21,6 +21,37 @@ from plugin.helpers import get_git_log
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
def is_method_like(method) -> bool:
|
||||
"""Check if a method is callable and not a property."""
|
||||
return any([
|
||||
callable(method),
|
||||
isinstance(method, classmethod),
|
||||
isinstance(method, staticmethod),
|
||||
isinstance(method, property),
|
||||
])
|
||||
|
||||
|
||||
def mark_final(method):
|
||||
"""Decorator to mark a method as 'final'.
|
||||
|
||||
This prevents subclasses from overriding this method.
|
||||
"""
|
||||
if not is_method_like(method):
|
||||
raise TypeError('mark_final can only be applied to functions')
|
||||
|
||||
method.__final__ = True
|
||||
return method
|
||||
|
||||
|
||||
def get_final_methods(cls):
|
||||
"""Find all methods of a class marked with the @mark_final decorator."""
|
||||
return [
|
||||
name
|
||||
for name, method in inspect.getmembers(cls)
|
||||
if getattr(method, '__final__', False) and is_method_like(method)
|
||||
]
|
||||
|
||||
|
||||
class PluginMixinEnum(StringEnum):
|
||||
"""Enumeration of the available plugin mixin types."""
|
||||
|
||||
@@ -57,6 +88,7 @@ class MetaBase:
|
||||
SLUG = None
|
||||
TITLE = None
|
||||
|
||||
@mark_final
|
||||
def get_meta_value(self, key: str, old_key: Optional[str] = None, __default=None):
|
||||
"""Reference a meta item with a key.
|
||||
|
||||
@@ -87,15 +119,18 @@ class MetaBase:
|
||||
return __default
|
||||
return value
|
||||
|
||||
@mark_final
|
||||
def plugin_name(self):
|
||||
"""Name of plugin."""
|
||||
return self.get_meta_value('NAME', 'PLUGIN_NAME')
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def name(self):
|
||||
"""Name of plugin."""
|
||||
return self.plugin_name()
|
||||
|
||||
@mark_final
|
||||
def plugin_slug(self):
|
||||
"""Slug of plugin.
|
||||
|
||||
@@ -108,10 +143,12 @@ class MetaBase:
|
||||
return slugify(slug.lower())
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def slug(self):
|
||||
"""Slug of plugin."""
|
||||
return self.plugin_slug()
|
||||
|
||||
@mark_final
|
||||
def plugin_title(self):
|
||||
"""Title of plugin."""
|
||||
title = self.get_meta_value('TITLE', 'PLUGIN_TITLE', None)
|
||||
@@ -120,28 +157,31 @@ class MetaBase:
|
||||
return self.plugin_name()
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def human_name(self):
|
||||
"""Human readable name of plugin."""
|
||||
return self.plugin_title()
|
||||
|
||||
@mark_final
|
||||
def plugin_config(self):
|
||||
"""Return the PluginConfig object associated with this plugin."""
|
||||
from plugin.registry import registry
|
||||
|
||||
return registry.get_plugin_config(self.plugin_slug())
|
||||
|
||||
def is_active(self):
|
||||
@mark_final
|
||||
def is_active(self) -> bool:
|
||||
"""Return True if this plugin is currently active."""
|
||||
# Mandatory plugins are always considered "active"
|
||||
if self.is_builtin and self.is_mandatory:
|
||||
if self.is_mandatory():
|
||||
return True
|
||||
|
||||
config = self.plugin_config()
|
||||
|
||||
if config:
|
||||
return config.active
|
||||
return config.is_active()
|
||||
|
||||
return False # pragma: no cover
|
||||
return False
|
||||
|
||||
|
||||
class MixinBase:
|
||||
@@ -156,11 +196,13 @@ class MixinBase:
|
||||
self._mixins = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@mark_final
|
||||
def mixin(self, key: str) -> bool:
|
||||
"""Check if mixin is registered."""
|
||||
key = str(key).lower()
|
||||
return key in self._mixins
|
||||
|
||||
@mark_final
|
||||
def mixin_enabled(self, key: str) -> bool:
|
||||
"""Check if mixin is registered, enabled and ready."""
|
||||
key = str(key).lower()
|
||||
@@ -181,6 +223,7 @@ class MixinBase:
|
||||
|
||||
return False
|
||||
|
||||
@mark_final
|
||||
def add_mixin(self, key: str, fnc_enabled=True, cls=None):
|
||||
"""Add a mixin to the plugins registry."""
|
||||
key = str(key).lower()
|
||||
@@ -188,6 +231,7 @@ class MixinBase:
|
||||
self._mixins[key] = fnc_enabled
|
||||
self.setup_mixin(key, cls=cls)
|
||||
|
||||
@mark_final
|
||||
def setup_mixin(self, key, cls=None):
|
||||
"""Define mixin details for the current mixin -> provides meta details for all active mixins."""
|
||||
# get human name
|
||||
@@ -200,6 +244,7 @@ class MixinBase:
|
||||
# register
|
||||
self._mixinreg[key] = {'key': key, 'human_name': human_name, 'cls': cls}
|
||||
|
||||
@mark_final
|
||||
def get_registered_mixins(self, with_base: bool = False, with_cls: bool = True):
|
||||
"""Get all registered mixins for the plugin."""
|
||||
mixins = getattr(self, '_mixinreg', None)
|
||||
@@ -220,6 +265,7 @@ class MixinBase:
|
||||
return mixins
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def registered_mixins(self, with_base: bool = False):
|
||||
"""Get all registered mixins for the plugin."""
|
||||
return self.get_registered_mixins(with_base=with_base)
|
||||
@@ -231,6 +277,7 @@ class VersionMixin:
|
||||
MIN_VERSION = None
|
||||
MAX_VERSION = None
|
||||
|
||||
@mark_final
|
||||
def check_version(self, latest=None) -> bool:
|
||||
"""Check if plugin functions for the current InvenTree version."""
|
||||
from InvenTree import version
|
||||
@@ -269,11 +316,33 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
|
||||
self.define_package()
|
||||
|
||||
def __init_subclass__(cls):
|
||||
"""Custom code to initialize a subclass of InvenTreePlugin.
|
||||
|
||||
This is a security measure to prevent plugins from overriding methods
|
||||
which are decorated with @mark_final.
|
||||
"""
|
||||
final_methods = get_final_methods(InvenTreePlugin)
|
||||
|
||||
child_methods = [
|
||||
name for name, method in cls.__dict__.items() if is_method_like(method)
|
||||
]
|
||||
|
||||
for name in child_methods:
|
||||
if name in final_methods:
|
||||
raise TypeError(
|
||||
f"Plugin '{cls.__name__}' cannot override final method '{name}' from InvenTreePlugin."
|
||||
)
|
||||
|
||||
return super().__init_subclass__()
|
||||
|
||||
@mark_final
|
||||
@classmethod
|
||||
def file(cls) -> Path:
|
||||
"""File that contains plugin definition."""
|
||||
return Path(inspect.getfile(cls))
|
||||
|
||||
@mark_final
|
||||
@classmethod
|
||||
def path(cls) -> Path:
|
||||
"""Path to plugins base folder."""
|
||||
@@ -296,6 +365,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
|
||||
# region properties
|
||||
@property
|
||||
@mark_final
|
||||
def description(self):
|
||||
"""Description of plugin."""
|
||||
description = self._get_value('DESCRIPTION', 'description')
|
||||
@@ -304,6 +374,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return description
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def author(self):
|
||||
"""Author of plugin - either from plugin settings or git."""
|
||||
author = self._get_value('AUTHOR', 'author')
|
||||
@@ -312,6 +383,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return author
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def pub_date(self):
|
||||
"""Publishing date of plugin - either from plugin settings or git."""
|
||||
pub_date = getattr(self, 'PUBLISH_DATE', None)
|
||||
@@ -323,16 +395,19 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return pub_date
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def version(self):
|
||||
"""Version of plugin."""
|
||||
return self._get_value('VERSION', 'version')
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def website(self):
|
||||
"""Website of plugin - if set else None."""
|
||||
return self._get_value('WEBSITE', 'website')
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def license(self):
|
||||
"""License of plugin."""
|
||||
return self._get_value('LICENSE', 'license')
|
||||
@@ -340,43 +415,52 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
# endregion
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
def check_is_package(cls):
|
||||
"""Is the plugin delivered as a package."""
|
||||
return getattr(cls, 'is_package', False)
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def _is_package(self):
|
||||
"""Is the plugin delivered as a package."""
|
||||
return getattr(self, 'is_package', False)
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
def check_is_sample(cls) -> bool:
|
||||
"""Is this plugin part of the samples?"""
|
||||
return str(cls.check_package_path()).startswith('plugin/samples/')
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def is_sample(self) -> bool:
|
||||
"""Is this plugin part of the samples?"""
|
||||
return self.check_is_sample()
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
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
|
||||
@mark_final
|
||||
def is_builtin(self) -> bool:
|
||||
"""Is this plugin is builtin."""
|
||||
return self.check_is_builtin()
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def is_mandatory(self) -> bool:
|
||||
"""Is this plugin mandatory (always forced to be active)."""
|
||||
from plugin.registry import registry
|
||||
config = self.plugin_config()
|
||||
if config:
|
||||
# If the plugin is configured, check if it is marked as mandatory
|
||||
return config.is_mandatory()
|
||||
|
||||
return self.slug in registry.MANDATORY_PLUGINS
|
||||
return False # pragma: no cover
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
def check_package_path(cls):
|
||||
"""Path to the plugin."""
|
||||
if cls.check_is_package():
|
||||
@@ -388,11 +472,13 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return cls.file()
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def package_path(self):
|
||||
"""Path to the plugin."""
|
||||
return self.check_package_path()
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
def check_package_install_name(cls) -> Union[str, None]:
|
||||
"""Installable package name of the plugin.
|
||||
|
||||
@@ -405,6 +491,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return getattr(cls, 'package_name', None)
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def package_install_name(self) -> Union[str, None]:
|
||||
"""Installable package name of the plugin.
|
||||
|
||||
@@ -417,6 +504,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return self.check_package_install_name()
|
||||
|
||||
@property
|
||||
@mark_final
|
||||
def settings_url(self) -> str:
|
||||
"""URL to the settings panel for this plugin."""
|
||||
if config := self.db:
|
||||
@@ -424,11 +512,13 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return InvenTree.helpers.pui_url('/settings/admin/plugin/')
|
||||
|
||||
# region package info
|
||||
@mark_final
|
||||
def _get_package_commit(self):
|
||||
"""Get last git commit for the plugin."""
|
||||
return get_git_log(str(self.file()))
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
def is_editable(cls):
|
||||
"""Returns if the current part is editable."""
|
||||
pkg_name = cls.__name__.split('.')[0]
|
||||
@@ -436,6 +526,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
return bool(len(dist_info) == 1)
|
||||
|
||||
@classmethod
|
||||
@mark_final
|
||||
def _get_package_metadata(cls):
|
||||
"""Get package metadata for plugin."""
|
||||
# Try simple metadata lookup
|
||||
@@ -482,6 +573,7 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
||||
|
||||
# endregion
|
||||
|
||||
@mark_final
|
||||
def plugin_static_file(self, *args) -> str:
|
||||
"""Construct a path to a static file within the plugin directory.
|
||||
|
||||
|
@@ -34,6 +34,7 @@ from InvenTree.ready import canAppAccessDatabase
|
||||
|
||||
from .helpers import (
|
||||
IntegrationPluginError,
|
||||
MixinNotImplementedError,
|
||||
get_entrypoints,
|
||||
get_plugins,
|
||||
handle_error,
|
||||
@@ -144,6 +145,8 @@ class PluginsRegistry:
|
||||
"""
|
||||
from common.models import InvenTreeSetting
|
||||
|
||||
logger.info('Initializing plugin registry')
|
||||
|
||||
self.ready = True
|
||||
|
||||
# Install plugins from file (if required)
|
||||
@@ -184,7 +187,13 @@ class PluginsRegistry:
|
||||
|
||||
plg = self.plugins[slug]
|
||||
|
||||
if active is not None and active != plg.is_active():
|
||||
config = self.get_plugin_config(slug)
|
||||
|
||||
if not config: # pragma: no cover
|
||||
logger.warning("Plugin '%s' has no configuration", slug)
|
||||
return None
|
||||
|
||||
if active is not None and active != config.is_active():
|
||||
return None
|
||||
|
||||
if with_mixin is not None and not plg.mixin_enabled(with_mixin):
|
||||
@@ -209,9 +218,12 @@ class PluginsRegistry:
|
||||
cfg = PluginConfig.objects.filter(key=slug).first()
|
||||
|
||||
if not cfg:
|
||||
logger.debug(
|
||||
"get_plugin_config: Creating new PluginConfig for '%s'", slug
|
||||
)
|
||||
cfg = PluginConfig.objects.create(key=slug)
|
||||
|
||||
except PluginConfig.DoesNotExist:
|
||||
except PluginConfig.DoesNotExist: # pragma: no cover
|
||||
return None
|
||||
except (IntegrityError, OperationalError, ProgrammingError): # pragma: no cover
|
||||
return None
|
||||
@@ -285,25 +297,44 @@ class PluginsRegistry:
|
||||
active (bool, optional): Filter by 'active' status of plugin. Defaults to True.
|
||||
builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None.
|
||||
"""
|
||||
try:
|
||||
# Pre-fetch the PluginConfig objects to avoid multiple database queries
|
||||
from plugin.models import PluginConfig
|
||||
|
||||
plugin_configs = PluginConfig.objects.all()
|
||||
|
||||
configs = {config.key: config for config in plugin_configs}
|
||||
except (ProgrammingError, OperationalError):
|
||||
# The database is not ready yet
|
||||
logger.warning('plugin.registry.with_mixin: Database not ready')
|
||||
return []
|
||||
|
||||
mixin = str(mixin).lower().strip()
|
||||
|
||||
result = []
|
||||
plugins = []
|
||||
|
||||
for plugin in self.plugins.values():
|
||||
if plugin.mixin_enabled(mixin):
|
||||
if active is not None:
|
||||
# Filter by 'active' status of plugin
|
||||
if active != plugin.is_active():
|
||||
continue
|
||||
try:
|
||||
if not plugin.mixin_enabled(mixin):
|
||||
continue
|
||||
except MixinNotImplementedError:
|
||||
continue
|
||||
|
||||
if builtin is not None:
|
||||
# Filter by 'builtin' status of plugin
|
||||
if builtin != plugin.is_builtin:
|
||||
continue
|
||||
config = configs.get(plugin.slug) or plugin.plugin_config()
|
||||
|
||||
result.append(plugin)
|
||||
# No config - cannot use this plugin
|
||||
if not config:
|
||||
continue
|
||||
|
||||
return result
|
||||
if active is not None and active != config.is_active():
|
||||
continue
|
||||
|
||||
if builtin is not None and builtin != config.is_builtin():
|
||||
continue
|
||||
|
||||
plugins.append(plugin)
|
||||
|
||||
return plugins
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -421,6 +452,15 @@ class PluginsRegistry:
|
||||
self.update_plugin_hash()
|
||||
logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins))
|
||||
|
||||
# Ensure that each loaded plugin has a valid configuration object in the database
|
||||
for plugin in self.plugins.values():
|
||||
config = self.get_plugin_config(plugin.slug)
|
||||
|
||||
# Ensure mandatory plugins are marked as active
|
||||
if config.is_mandatory() and not config.active:
|
||||
config.active = True
|
||||
config.save(no_reload=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Unexpected error during plugin reload: %s', e)
|
||||
log_error('reload_plugins', scope='plugins')
|
||||
@@ -611,8 +651,8 @@ class PluginsRegistry:
|
||||
plugin.db = plg_db
|
||||
|
||||
# Check if this is a 'builtin' plugin
|
||||
builtin = plugin.check_is_builtin()
|
||||
sample = plugin.check_is_sample()
|
||||
builtin = plg_db.is_builtin() if plg_db else plugin.check_is_builtin()
|
||||
sample = plg_db.is_sample() if plg_db else plugin.check_is_sample()
|
||||
|
||||
package_name = None
|
||||
|
||||
@@ -621,7 +661,7 @@ class PluginsRegistry:
|
||||
package_name = getattr(plugin, 'package_name', None)
|
||||
|
||||
# Auto-enable default builtin plugins
|
||||
if builtin and plg_db and plg_db.is_mandatory():
|
||||
if plg_db and plg_db.is_mandatory():
|
||||
if not plg_db.active:
|
||||
plg_db.active = True
|
||||
plg_db.save()
|
||||
@@ -631,11 +671,16 @@ class PluginsRegistry:
|
||||
plg_db.package_name = package_name
|
||||
plg_db.save()
|
||||
|
||||
# Check if this plugin is considered 'mandatory'
|
||||
mandatory = (
|
||||
plg_key in self.MANDATORY_PLUGINS or plg_key in settings.PLUGINS_MANDATORY
|
||||
)
|
||||
|
||||
# Determine if this plugin should be loaded:
|
||||
# - If PLUGIN_TESTING is enabled
|
||||
# - If this is a 'builtin' plugin
|
||||
# - If this is a 'mandatory' plugin
|
||||
# - If this plugin has been explicitly enabled by the user
|
||||
if settings.PLUGIN_TESTING or builtin or (plg_db and plg_db.active):
|
||||
if settings.PLUGIN_TESTING or mandatory or (plg_db and plg_db.active):
|
||||
# Initialize package - we can be sure that an admin has activated the plugin
|
||||
logger.debug('Loading plugin `%s`', plg_name)
|
||||
|
||||
@@ -668,6 +713,15 @@ class PluginsRegistry:
|
||||
plg_i: InvenTreePlugin = plugin()
|
||||
dt = time.time() - t_start
|
||||
logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt)
|
||||
|
||||
if mandatory and not plg_db.active: # pragma: no cover
|
||||
# If this is a mandatory plugin, ensure it is marked as active
|
||||
logger.info(
|
||||
'Plugin `%s` is a mandatory plugin - activating', plg_name
|
||||
)
|
||||
plg_db.active = True
|
||||
plg_db.save()
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
raise e
|
||||
except Exception as error:
|
||||
|
@@ -234,9 +234,14 @@ class PluginActivateSerializer(serializers.Serializer):
|
||||
help_text=_('Activate this plugin'),
|
||||
)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
def update(self, instance: PluginConfig, validated_data):
|
||||
"""Apply the new 'active' value to the plugin instance."""
|
||||
instance.activate(validated_data.get('active', True))
|
||||
active = validated_data.get('active', True)
|
||||
|
||||
if not active and instance.is_mandatory():
|
||||
raise ValidationError(_('Mandatory plugin cannot be deactivated'))
|
||||
|
||||
instance.activate(active)
|
||||
return instance
|
||||
|
||||
|
||||
|
@@ -23,12 +23,48 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
self.PKG_URL = 'git+https://github.com/inventree/inventree-brother-plugin'
|
||||
super().setUp()
|
||||
|
||||
def test_plugin_uninstall(self):
|
||||
"""Test plugin uninstall command."""
|
||||
# invalid package name
|
||||
url = reverse('api-plugin-uninstall', kwargs={'plugin': 'samplexx'})
|
||||
|
||||
# Requires superuser permissions
|
||||
self.patch(url, expected_code=403)
|
||||
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
# Invalid slug (404 error)
|
||||
self.patch(url, expected_code=404)
|
||||
|
||||
url = reverse('api-plugin-uninstall', kwargs={'plugin': 'sample'})
|
||||
|
||||
data = self.patch(url, expected_code=400).data
|
||||
|
||||
plugs = {
|
||||
'sample': 'Plugin cannot be uninstalled as it is a sample plugin',
|
||||
'bom-exporter': 'Plugin cannot be uninstalled as it is currently active',
|
||||
'inventree-slack-notification': 'Plugin cannot be uninstalled as it is a built-in plugin',
|
||||
}
|
||||
|
||||
for slug, msg in plugs.items():
|
||||
url = reverse('api-plugin-uninstall', kwargs={'plugin': slug})
|
||||
data = self.patch(url, expected_code=400).data
|
||||
self.assertIn(msg, str(data))
|
||||
|
||||
with self.settings(PLUGINS_INSTALL_DISABLED=True):
|
||||
url = reverse('api-plugin-uninstall', kwargs={'plugin': 'bom-exporter'})
|
||||
data = self.patch(url, expected_code=400).data
|
||||
self.assertIn(
|
||||
'Plugin uninstalling is disabled', str(data['non_field_errors'])
|
||||
)
|
||||
|
||||
def test_plugin_install(self):
|
||||
"""Test the plugin install command."""
|
||||
url = reverse('api-plugin-install')
|
||||
|
||||
# invalid package name
|
||||
self.post(
|
||||
data = self.post(
|
||||
url,
|
||||
{
|
||||
'confirm': True,
|
||||
@@ -36,7 +72,12 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
},
|
||||
expected_code=400,
|
||||
max_query_time=60,
|
||||
).data
|
||||
|
||||
self.assertIn(
|
||||
'ERROR: Could not find a version that satisfies the requirement', str(data)
|
||||
)
|
||||
self.assertIn('ERROR: No matching distribution found for', str(data))
|
||||
|
||||
# valid - Pypi
|
||||
data = self.post(
|
||||
@@ -69,7 +110,8 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
# invalid tries
|
||||
# no input
|
||||
self.post(url, {}, expected_code=400)
|
||||
data = self.post(url, {}, expected_code=400).data
|
||||
self.assertIn('This field is required.', str(data['confirm']))
|
||||
|
||||
# no package info
|
||||
data = self.post(url, {'confirm': True}, expected_code=400).data
|
||||
@@ -80,7 +122,8 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
# not confirmed
|
||||
self.post(url, {'packagename': self.PKG_NAME}, expected_code=400)
|
||||
data = self.post(url, {'packagename': self.PKG_NAME}, expected_code=400).data
|
||||
self.assertIn('This field is required.', str(data['confirm']))
|
||||
|
||||
data = self.post(
|
||||
url, {'packagename': self.PKG_NAME, 'confirm': False}, expected_code=400
|
||||
@@ -90,19 +133,41 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
data['confirm'][0].title().upper(), 'Installation not confirmed'.upper()
|
||||
)
|
||||
|
||||
# install disabled
|
||||
# Plugin installation disabled
|
||||
with self.settings(PLUGINS_INSTALL_DISABLED=True):
|
||||
self.post(url, {}, expected_code=400)
|
||||
response = self.post(
|
||||
url,
|
||||
{'packagename': 'inventree-order-history', 'confirm': True},
|
||||
expected_code=400,
|
||||
)
|
||||
self.assertIn(
|
||||
'Plugin installation is disabled',
|
||||
str(response.data['non_field_errors']),
|
||||
)
|
||||
|
||||
def test_plugin_deactivate_mandatory(self):
|
||||
"""Test deactivating a mandatory plugin."""
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
|
||||
# Get a mandatory plugin
|
||||
plg = PluginConfig.objects.filter(key='bom-exporter').first()
|
||||
assert plg is not None
|
||||
|
||||
url = reverse('api-plugin-detail-activate', kwargs={'plugin': plg.key})
|
||||
|
||||
# Try to deactivate the mandatory plugin
|
||||
response = self.client.patch(url, {'active': False}, follow=True)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Mandatory plugin cannot be deactivated', str(response.data))
|
||||
|
||||
def test_plugin_activate(self):
|
||||
"""Test the plugin activate."""
|
||||
test_plg = self.plugin_confs.first()
|
||||
assert test_plg is not None
|
||||
|
||||
def assert_plugin_active(self, active):
|
||||
plgs = PluginConfig.objects.all().first()
|
||||
assert plgs is not None
|
||||
self.assertEqual(plgs.active, active)
|
||||
"""Test the plugin activation API endpoint."""
|
||||
test_plg = PluginConfig.objects.get(key='samplelocate')
|
||||
self.assertIsNotNone(test_plg, 'Test plugin not found')
|
||||
self.assertFalse(test_plg.is_active())
|
||||
self.assertFalse(test_plg.is_builtin())
|
||||
self.assertFalse(test_plg.is_mandatory())
|
||||
|
||||
url = reverse('api-plugin-detail-activate', kwargs={'plugin': test_plg.key})
|
||||
|
||||
@@ -119,20 +184,27 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
test_plg.save()
|
||||
|
||||
# Activate plugin with detail url
|
||||
assert_plugin_active(self, False)
|
||||
test_plg.refresh_from_db()
|
||||
self.assertFalse(test_plg.is_active())
|
||||
|
||||
response = self.client.patch(url, {}, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
assert_plugin_active(self, True)
|
||||
|
||||
test_plg.refresh_from_db()
|
||||
self.assertTrue(test_plg.is_active())
|
||||
|
||||
# Deactivate plugin
|
||||
test_plg.active = False
|
||||
test_plg.save()
|
||||
|
||||
# Activate plugin
|
||||
assert_plugin_active(self, False)
|
||||
test_plg.refresh_from_db()
|
||||
self.assertFalse(test_plg.active)
|
||||
response = self.client.patch(url, {}, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
assert_plugin_active(self, True)
|
||||
|
||||
test_plg.refresh_from_db()
|
||||
self.assertTrue(test_plg.is_active())
|
||||
|
||||
def test_pluginCfg_delete(self):
|
||||
"""Test deleting a config."""
|
||||
@@ -228,7 +300,21 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
plg_inactive.active = True
|
||||
plg_inactive.save()
|
||||
self.assertEqual(cm.warning.args[0], 'A plugin registry reload was triggered')
|
||||
|
||||
self.assertEqual(
|
||||
cm.warning.args[0],
|
||||
f'A plugin registry reload was triggered for plugin {plg_inactive.key}',
|
||||
)
|
||||
|
||||
# Set active state back to False
|
||||
with self.assertWarns(Warning) as cm:
|
||||
plg_inactive.active = False
|
||||
plg_inactive.save()
|
||||
|
||||
self.assertEqual(
|
||||
cm.warning.args[0],
|
||||
f'A plugin registry reload was triggered for plugin {plg_inactive.key}',
|
||||
)
|
||||
|
||||
def test_check_plugin(self):
|
||||
"""Test check_plugin function."""
|
||||
@@ -404,3 +490,107 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
|
||||
def test_plugin_filter_by_mixin(self):
|
||||
"""Test filtering plugins by mixin."""
|
||||
from plugin import PluginMixinEnum
|
||||
from plugin.registry import registry
|
||||
|
||||
# Ensure we have some plugins loaded
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
|
||||
url = reverse('api-plugin-list')
|
||||
|
||||
# Filter by 'mixin' parameter
|
||||
mixin_results = {
|
||||
PluginMixinEnum.BARCODE: 5,
|
||||
PluginMixinEnum.EXPORTER: 3,
|
||||
PluginMixinEnum.ICON_PACK: 1,
|
||||
PluginMixinEnum.MAIL: 1,
|
||||
PluginMixinEnum.NOTIFICATION: 3,
|
||||
PluginMixinEnum.USER_INTERFACE: 1,
|
||||
}
|
||||
|
||||
for mixin, expected_count in mixin_results.items():
|
||||
data = self.get(url, {'mixin': mixin}).data
|
||||
|
||||
self.assertEqual(len(data), expected_count)
|
||||
|
||||
if expected_count > 0:
|
||||
for item in data:
|
||||
self.assertIn(mixin, item['mixins'])
|
||||
|
||||
def test_plugin_filters(self):
|
||||
"""Unit testing for plugin API filters."""
|
||||
from plugin.models import PluginConfig
|
||||
from plugin.registry import registry
|
||||
|
||||
PluginConfig.objects.all().delete()
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
|
||||
N = PluginConfig.objects.count()
|
||||
self.assertGreater(N, 0)
|
||||
|
||||
url = reverse('api-plugin-list')
|
||||
|
||||
data = self.get(url).data
|
||||
|
||||
self.assertGreater(len(data), 0)
|
||||
self.assertEqual(len(data), N)
|
||||
|
||||
# Filter by 'builtin' plugins
|
||||
data = self.get(url, {'builtin': 'true'}).data
|
||||
|
||||
Y_BUILTIN = len(data)
|
||||
|
||||
for item in data:
|
||||
self.assertTrue(item['is_builtin'])
|
||||
|
||||
data = self.get(url, {'builtin': 'false'}).data
|
||||
|
||||
N_BUILTIN = len(data)
|
||||
|
||||
for item in data:
|
||||
self.assertFalse(item['is_builtin'])
|
||||
|
||||
self.assertGreater(Y_BUILTIN, 0)
|
||||
self.assertGreater(N_BUILTIN, 0)
|
||||
|
||||
self.assertEqual(N_BUILTIN + Y_BUILTIN, N)
|
||||
|
||||
# Filter by 'active' status
|
||||
Y_ACTIVE = len(self.get(url, {'active': 'true'}).data)
|
||||
N_ACTIVE = len(self.get(url, {'active': 'false'}).data)
|
||||
|
||||
self.assertGreater(Y_ACTIVE, 0)
|
||||
self.assertGreater(N_ACTIVE, 0)
|
||||
|
||||
self.assertEqual(Y_ACTIVE + N_ACTIVE, N)
|
||||
|
||||
# Filter by 'sample' status
|
||||
Y_SAMPLE = len(self.get(url, {'sample': 'true'}).data)
|
||||
N_SAMPLE = len(self.get(url, {'sample': 'false'}).data)
|
||||
|
||||
self.assertGreater(Y_SAMPLE, 0)
|
||||
self.assertGreater(N_SAMPLE, 0)
|
||||
|
||||
self.assertEqual(Y_SAMPLE + N_SAMPLE, N)
|
||||
|
||||
# Filter by 'mandatory' status`
|
||||
Y_MANDATORY = len(self.get(url, {'mandatory': 'true'}).data)
|
||||
N_MANDATORY = len(self.get(url, {'mandatory': 'false'}).data)
|
||||
|
||||
self.assertGreater(Y_MANDATORY, 0)
|
||||
self.assertGreater(N_MANDATORY, 0)
|
||||
|
||||
self.assertEqual(Y_MANDATORY + N_MANDATORY, N)
|
||||
|
||||
# Add in a new mandatory plugin
|
||||
with self.settings(PLUGINS_MANDATORY=['samplelocate']):
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
|
||||
Y_MANDATORY_2 = len(self.get(url, {'mandatory': 'true'}).data)
|
||||
N_MANDATORY_2 = len(self.get(url, {'mandatory': 'false'}).data)
|
||||
|
||||
self.assertEqual(Y_MANDATORY_2, Y_MANDATORY + 1)
|
||||
self.assertEqual(N_MANDATORY_2, N_MANDATORY - 1)
|
||||
|
@@ -13,7 +13,9 @@ from unittest.mock import patch
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
import plugin.templatetags.plugin_extras as plugin_tags
|
||||
from plugin import InvenTreePlugin, registry
|
||||
from InvenTree.unit_test import PluginRegistryMixin, TestQueryMixin
|
||||
from plugin import InvenTreePlugin, PluginMixinEnum
|
||||
from plugin.registry import registry
|
||||
from plugin.samples.integration.another_sample import (
|
||||
NoIntegrationPlugin,
|
||||
WrongIntegrationPlugin,
|
||||
@@ -24,7 +26,7 @@ from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||
PLUGIN_TEST_DIR = '_testfolder/test_plugins'
|
||||
|
||||
|
||||
class PluginTagTests(TestCase):
|
||||
class PluginTagTests(PluginRegistryMixin, TestCase):
|
||||
"""Tests for the plugin extras."""
|
||||
|
||||
def setUp(self):
|
||||
@@ -60,7 +62,9 @@ class PluginTagTests(TestCase):
|
||||
|
||||
def test_mixin_available(self):
|
||||
"""Check that mixin_available works."""
|
||||
self.assertEqual(plugin_tags.mixin_available('barcode'), True)
|
||||
from plugin import PluginMixinEnum
|
||||
|
||||
self.assertEqual(plugin_tags.mixin_available(PluginMixinEnum.BARCODE), True)
|
||||
self.assertEqual(plugin_tags.mixin_available('wrong'), False)
|
||||
|
||||
def test_tag_safe_url(self):
|
||||
@@ -203,7 +207,7 @@ class InvenTreePluginTests(TestCase):
|
||||
self.assertEqual(plug.is_active(), False)
|
||||
|
||||
|
||||
class RegistryTests(TestCase):
|
||||
class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase):
|
||||
"""Tests for registry loading methods."""
|
||||
|
||||
def mockDir(self) -> str:
|
||||
@@ -282,18 +286,76 @@ class RegistryTests(TestCase):
|
||||
|
||||
self.assertEqual(len(registry.errors), 3)
|
||||
|
||||
# There should be at least one discovery error in the module `broken_file`
|
||||
self.assertGreater(len(registry.errors.get('discovery')), 0)
|
||||
self.assertEqual(
|
||||
registry.errors.get('discovery')[0]['broken_file'],
|
||||
"name 'bb' is not defined",
|
||||
errors = registry.errors
|
||||
|
||||
def find_error(group: str, key: str) -> str:
|
||||
"""Find a matching error in the registry errors."""
|
||||
for error in errors.get(group, []):
|
||||
if key in error:
|
||||
return error[key]
|
||||
return None
|
||||
|
||||
# Check for expected errors in the registry
|
||||
self.assertIn(
|
||||
"Plugin 'BadActorPlugin' cannot override final method 'plugin_slug'",
|
||||
find_error('discovery', 'bad_actor'),
|
||||
)
|
||||
|
||||
# There should be at least one load error with an intentional KeyError
|
||||
self.assertGreater(len(registry.errors.get('Test:init_plugin')), 0)
|
||||
self.assertEqual(
|
||||
registry.errors.get('Test:init_plugin')[0]['broken_sample'],
|
||||
"'This is a dummy error'",
|
||||
self.assertIn(
|
||||
"name 'bb' is not defined", find_error('discovery', 'broken_file')
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'This is a dummy error', find_error('Test:init_plugin', 'broken_sample')
|
||||
)
|
||||
|
||||
def test_plugin_override_mandatory(self):
|
||||
"""Test that a plugin cannot override the is_mandatory method."""
|
||||
with self.assertRaises(TypeError) as e:
|
||||
# Attempt to create a class which overrides the 'is_mandatory' method
|
||||
class MyDummyPlugin(InvenTreePlugin):
|
||||
"""A dummy plugin for testing."""
|
||||
|
||||
NAME = 'MyDummyPlugin'
|
||||
SLUG = 'mydummyplugin'
|
||||
TITLE = 'My Dummy Plugin'
|
||||
VERSION = '1.0.0'
|
||||
|
||||
def is_mandatory(self):
|
||||
"""Override is_mandatory to always return True."""
|
||||
return True
|
||||
|
||||
# Check that the error message is as expected
|
||||
self.assertIn(
|
||||
"Plugin 'MyDummyPlugin' cannot override final method 'is_mandatory' from InvenTreePlugin",
|
||||
str(e.exception),
|
||||
)
|
||||
|
||||
def test_plugin_override_active(self):
|
||||
"""Test that the plugin override works as expected."""
|
||||
with self.assertRaises(TypeError) as e:
|
||||
# Attempt to create a class which overrides the 'is_active' method
|
||||
class MyDummyPlugin(InvenTreePlugin):
|
||||
"""A dummy plugin for testing."""
|
||||
|
||||
NAME = 'MyDummyPlugin'
|
||||
SLUG = 'mydummyplugin'
|
||||
TITLE = 'My Dummy Plugin'
|
||||
VERSION = '1.0.0'
|
||||
|
||||
def is_active(self):
|
||||
"""Override is_active to always return True."""
|
||||
return True
|
||||
|
||||
def __init_subclass__(cls):
|
||||
"""Override __init_subclass__."""
|
||||
# Ensure that overriding the __init_subclass__ method
|
||||
# does not prevent the TypeError from being raised
|
||||
|
||||
# Check that the error message is as expected
|
||||
self.assertIn(
|
||||
"Plugin 'MyDummyPlugin' cannot override final method 'is_active' from InvenTreePlugin",
|
||||
str(e.exception),
|
||||
)
|
||||
|
||||
@override_settings(PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
|
||||
@@ -418,3 +480,155 @@ class RegistryTests(TestCase):
|
||||
# Check that changed hashes run through
|
||||
registry.registry_hash = 'abc'
|
||||
self.assertTrue(registry.check_reload())
|
||||
|
||||
def test_builtin_mandatory_plugins(self):
|
||||
"""Test that mandatory builtin plugins are always loaded."""
|
||||
from plugin.models import PluginConfig
|
||||
from plugin.registry import registry
|
||||
|
||||
# Start with a 'clean slate'
|
||||
PluginConfig.objects.all().delete()
|
||||
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
mandatory = registry.MANDATORY_PLUGINS
|
||||
self.assertEqual(len(mandatory), 9)
|
||||
|
||||
# Check that the mandatory plugins are loaded
|
||||
self.assertEqual(
|
||||
PluginConfig.objects.filter(active=True).count(), len(mandatory)
|
||||
)
|
||||
|
||||
for key in mandatory:
|
||||
cfg = registry.get_plugin_config(key)
|
||||
self.assertIsNotNone(cfg, f"Mandatory plugin '{key}' not found in config")
|
||||
self.assertTrue(cfg.is_mandatory())
|
||||
self.assertTrue(cfg.active, f"Mandatory plugin '{key}' is not active")
|
||||
self.assertTrue(cfg.is_active())
|
||||
self.assertTrue(cfg.is_builtin())
|
||||
plg = registry.get_plugin(key)
|
||||
self.assertIsNotNone(plg, f"Mandatory plugin '{key}' not found")
|
||||
self.assertTrue(
|
||||
plg.is_mandatory, f"Plugin '{key}' is not marked as mandatory"
|
||||
)
|
||||
|
||||
slug = 'bom-exporter'
|
||||
self.assertIn(slug, mandatory)
|
||||
cfg = registry.get_plugin_config(slug)
|
||||
|
||||
# Try to disable the mandatory plugin
|
||||
cfg.active = False
|
||||
cfg.save()
|
||||
cfg.refresh_from_db()
|
||||
|
||||
# Mandatory plugin cannot be disabled!
|
||||
self.assertTrue(cfg.active)
|
||||
self.assertTrue(cfg.is_active())
|
||||
|
||||
def test_mandatory_plugins(self):
|
||||
"""Test that plugins marked as 'mandatory' are always active."""
|
||||
from plugin.models import PluginConfig
|
||||
from plugin.registry import registry
|
||||
|
||||
# Start with a 'clean slate'
|
||||
PluginConfig.objects.all().delete()
|
||||
|
||||
self.assertEqual(PluginConfig.objects.count(), 0)
|
||||
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
|
||||
N_CONFIG = PluginConfig.objects.count()
|
||||
N_ACTIVE = PluginConfig.objects.filter(active=True).count()
|
||||
|
||||
# Run checks across the registered plugin configurations
|
||||
self.assertGreater(N_CONFIG, 0, 'No plugin configs found after reload')
|
||||
self.assertGreater(N_ACTIVE, 0, 'No active plugin configs found after reload')
|
||||
self.assertLess(
|
||||
N_ACTIVE, N_CONFIG, 'All plugins are installed, but only some are active'
|
||||
)
|
||||
self.assertEqual(
|
||||
N_ACTIVE,
|
||||
len(registry.MANDATORY_PLUGINS),
|
||||
'Not all mandatory plugins are active',
|
||||
)
|
||||
|
||||
# Next, mark some additional plugins as mandatory
|
||||
# These are a mix of "builtin" and "sample" plugins
|
||||
mandatory_slugs = ['sampleui', 'validator', 'digikeyplugin', 'autocreatebuilds']
|
||||
|
||||
with self.settings(PLUGINS_MANDATORY=mandatory_slugs):
|
||||
# Reload the plugins to apply the mandatory settings
|
||||
registry.reload_plugins(full_reload=True, collect=True)
|
||||
|
||||
self.assertEqual(N_CONFIG, PluginConfig.objects.count())
|
||||
self.assertEqual(
|
||||
N_ACTIVE + 4, PluginConfig.objects.filter(active=True).count()
|
||||
)
|
||||
|
||||
# Check that the mandatory plugins are active
|
||||
for slug in mandatory_slugs:
|
||||
cfg = registry.get_plugin_config(slug)
|
||||
self.assertIsNotNone(
|
||||
cfg, f"Mandatory plugin '{slug}' not found in config"
|
||||
)
|
||||
self.assertTrue(cfg.is_mandatory())
|
||||
self.assertTrue(cfg.active, f"Mandatory plugin '{slug}' is not active")
|
||||
self.assertTrue(cfg.is_active())
|
||||
plg = registry.get_plugin(slug)
|
||||
self.assertTrue(plg.is_active(), f"Plugin '{slug}' is not active")
|
||||
self.assertIsNotNone(plg, f"Mandatory plugin '{slug}' not found")
|
||||
self.assertTrue(
|
||||
plg.is_mandatory, f"Plugin '{slug}' is not marked as mandatory"
|
||||
)
|
||||
|
||||
def test_with_mixin(self):
|
||||
"""Tests for the 'with_mixin' registry method."""
|
||||
from plugin.models import PluginConfig
|
||||
from plugin.registry import registry
|
||||
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
N_CONFIG = PluginConfig.objects.count()
|
||||
self.assertGreater(N_CONFIG, 0, 'No plugin configs found')
|
||||
|
||||
# Test that the 'with_mixin' method is query efficient
|
||||
for mixin in PluginMixinEnum:
|
||||
with self.assertNumQueriesLessThan(3):
|
||||
registry.with_mixin(mixin)
|
||||
|
||||
# Test for the 'base' mixin - we expect that this returns "all" plugins
|
||||
base = registry.with_mixin(PluginMixinEnum.BASE, active=None, builtin=None)
|
||||
self.assertEqual(len(base), N_CONFIG, 'Base mixin does not return all plugins')
|
||||
|
||||
# Next, fetch only "active" plugins
|
||||
n_active = len(registry.with_mixin(PluginMixinEnum.BASE, active=True))
|
||||
self.assertGreater(n_active, 0, 'No active plugins found with base mixin')
|
||||
self.assertLess(n_active, N_CONFIG, 'All plugins are active with base mixin')
|
||||
|
||||
n_inactive = len(registry.with_mixin(PluginMixinEnum.BASE, active=False))
|
||||
|
||||
self.assertGreater(n_inactive, 0, 'No inactive plugins found with base mixin')
|
||||
self.assertLess(n_inactive, N_CONFIG, 'All plugins are active with base mixin')
|
||||
self.assertEqual(
|
||||
n_active + n_inactive, N_CONFIG, 'Active and inactive plugins do not match'
|
||||
)
|
||||
|
||||
# Filter by 'builtin' status
|
||||
plugins = registry.with_mixin(PluginMixinEnum.LABELS, builtin=True, active=True)
|
||||
self.assertEqual(len(plugins), 2)
|
||||
|
||||
keys = [p.slug for p in plugins]
|
||||
self.assertIn('inventreelabel', keys)
|
||||
self.assertIn('inventreelabelmachine', keys)
|
||||
|
||||
def test_config_attributes(self):
|
||||
"""Test attributes for PluginConfig objects."""
|
||||
self.ensurePluginsLoaded()
|
||||
|
||||
cfg = registry.get_plugin_config('bom-exporter')
|
||||
self.assertIsNotNone(cfg, 'PluginConfig for bom-exporter not found')
|
||||
|
||||
self.assertTrue(cfg.is_mandatory())
|
||||
self.assertTrue(cfg.is_active())
|
||||
self.assertTrue(cfg.is_builtin())
|
||||
self.assertFalse(cfg.is_package())
|
||||
self.assertFalse(cfg.is_sample())
|
||||
|
@@ -100,26 +100,18 @@ class LabelPrint(GenericAPIView):
|
||||
|
||||
def get_plugin_class(self, plugin_slug: str, raise_error=False):
|
||||
"""Return the plugin class for the given plugin key."""
|
||||
from plugin.models import PluginConfig
|
||||
from plugin import registry
|
||||
|
||||
if not plugin_slug:
|
||||
# Use the default label printing plugin
|
||||
plugin_slug = InvenTreeLabelPlugin.NAME.lower()
|
||||
|
||||
plugin = None
|
||||
|
||||
try:
|
||||
plugin_config = PluginConfig.objects.get(key=plugin_slug)
|
||||
plugin = plugin_config.plugin
|
||||
except (ValueError, PluginConfig.DoesNotExist):
|
||||
pass
|
||||
plugin = registry.get_plugin(plugin_slug, active=True)
|
||||
|
||||
error = None
|
||||
|
||||
if not plugin:
|
||||
error = _('Plugin not found')
|
||||
elif not plugin.is_active():
|
||||
error = _('Plugin is not active')
|
||||
elif not plugin.mixin_enabled(PluginMixinEnum.LABELS):
|
||||
error = _('Plugin does not support label printing')
|
||||
|
||||
|
@@ -393,7 +393,7 @@ class PrintTestMixins:
|
||||
},
|
||||
expected_code=201,
|
||||
max_query_time=15,
|
||||
max_query_count=500 * len(qs),
|
||||
max_query_count=150 * len(qs),
|
||||
)
|
||||
|
||||
# Test with wrong dimensions
|
||||
|
@@ -1593,13 +1593,10 @@ class StockItemTest(StockAPITestCase):
|
||||
|
||||
self.assertIn('This field is required', str(response.data['location']))
|
||||
|
||||
# TODO: Return to this and work out why it is taking so long
|
||||
# Ref: https://github.com/inventree/InvenTree/pull/7157
|
||||
response = self.post(
|
||||
url,
|
||||
{'location': '1', 'notes': 'Returned from this customer for testing'},
|
||||
expected_code=201,
|
||||
max_query_time=5.0,
|
||||
)
|
||||
|
||||
item.refresh_from_db()
|
||||
|
@@ -80,7 +80,8 @@ export default defineConfig({
|
||||
INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000',
|
||||
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
|
||||
INVENTREE_COOKIE_SAMESITE: 'False',
|
||||
INVENTREE_LOGIN_ATTEMPTS: '100'
|
||||
INVENTREE_LOGIN_ATTEMPTS: '100',
|
||||
INVENTREE_PLUGINS_MANDATORY: 'samplelocate'
|
||||
},
|
||||
url: 'http://localhost:8000/api/',
|
||||
reuseExistingServer: IS_CI,
|
||||
|
@@ -137,6 +137,18 @@ test('Plugins - Functionality', async ({ browser }) => {
|
||||
await page.getByRole('menuitem', { name: 'Deactivate' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('The plugin was deactivated').waitFor();
|
||||
|
||||
// Check for custom "mandatory" plugin
|
||||
await clearTableFilters(page);
|
||||
await setTableChoiceFilter(page, 'Mandatory', 'Yes');
|
||||
await setTableChoiceFilter(page, 'Sample', 'Yes');
|
||||
await setTableChoiceFilter(page, 'Builtin', 'No');
|
||||
|
||||
await page.getByText('1 - 1 / 1').waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: 'SampleLocatePlugin' })
|
||||
.first()
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Plugins - Panels', async ({ browser, request }) => {
|
||||
|
Reference in New Issue
Block a user