diff --git a/docs/docs/plugins/how_to.md b/docs/docs/plugins/develop.md similarity index 55% rename from docs/docs/plugins/how_to.md rename to docs/docs/plugins/develop.md index a6e4b5698f..75738f6569 100644 --- a/docs/docs/plugins/how_to.md +++ b/docs/docs/plugins/develop.md @@ -10,7 +10,7 @@ This page serves as a short introductory guide for plugin beginners. It should b We strongly recommend that you use the [Plugin Creator](./creator.md) tool when first scaffolding your new plugin. This tool will help you to create a basic plugin structure, and will also provide you with a set of example files which can be used as a starting point for your own plugin development. -### Determine Requirements +## Determine Requirements Before starting, you should have a clear understanding of what you want your plugin to do. In particular, consider the functionality provided by the available [plugin mixins](./index.md#plugin-mixins), and whether your plugin can be built using these mixins. @@ -47,8 +47,122 @@ from plugin.mixins import APICallMixin, SettingsMixin, ScheduleMixin, BarcodeMix - Use GitHub actions to test your plugin regularly (you can [schedule actions](https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule)) against the 'latest' [docker-build](https://hub.docker.com/r/inventree/inventree) of InvenTree - If you use the AppMixin pin your plugin against the stable branch of InvenTree, your migrations might get messed up otherwise -### Packaging +## Plugin Code Structure + +### Plugin Base Class + +Custom plugins must inherit from the [InvenTreePlugin class]({{ sourcefile("src/backend/InvenTree/plugin/plugin.py") }}). Any plugins installed via the methods outlined above will be "discovered" when the InvenTree server launches. + +### Imports + +As the code base is evolving import paths might change. Therefore we provide stable import targets for important python APIs. +Please read all release notes and watch out for warnings - we generally provide backports for depreciated interfaces for at least one minor release. + +#### Plugins + +General classes and mechanisms are provided under the `plugin` [namespaces]({{ sourcefile("src/backend/InvenTree/plugin/__init__.py") }}). These include: + +```python +# Management objects +registry # Object that manages all plugin states and integrations + +# Base classes +InvenTreePlugin # Base class for all plugins + +# Errors +MixinImplementationError # Is raised if a mixin is implemented wrong (default not overwritten for example) +MixinNotImplementedError # Is raised if a mixin was not implemented (core mechanisms are missing from the plugin) +``` + +#### Mixins + +Plugin functionality is split between multiple "mixin" classes - each of which provides a specific set of features or behaviors that can be integrated into a plugin. These mixins are designed to be used in conjunction with the `InvenTreePlugin` base class, allowing developers to easily extend the functionality of their plugins. All public APIs that should be used are exposed under `plugin.mixins`. These include all built-in mixins and notification methods. An up-to-date reference can be found in the source code [can be found here]({{ sourcefile("src/backend/InvenTree/plugin/mixins/__init__.py") }}). + +Refer to the [mixin documentation](#plugin-mixins) for a list of available mixins, and their usage. + +#### Models and other internal InvenTree APIs + +!!! warning "Danger Zone" + The APIs outside of the `plugin` namespace are not structured for public usage and require a more in-depth knowledge of the Django framework. Please ask in GitHub discussions of the `InvenTree` org if you are not sure you are using something the intended way. + +We do not provide stable interfaces to models or any other internal python APIs. If you need to integrate into these parts please make yourself familiar with the codebase. We follow general Django patterns and only stray from them in limited, special cases. +If you need to react to state changes please use the [EventMixin](./mixins/event.md). + +### Plugin Options + +Some metadata options can be defined as constants in the plugins class. + +``` python +NAME = '' # Used as a general reference to the plugin +SLUG = None # Used in URLs, setting-names etc. when a unique slug as a reference is needed -> the plugin name is used if not set +TITLE = None # A nice human friendly name for the plugin -> used in titles, as plugin name etc. + +AUTHOR = None # Author of the plugin, git commit information is used if not present +PUBLISH_DATE = None # Publishing date of the plugin, git commit information is used if not present +WEBSITE = None # Website for the plugin, developer etc. -> is shown in plugin overview if set + +VERSION = None # Version of the plugin +MIN_VERSION = None # Lowest InvenTree version number that is supported by the plugin +MAX_VERSION = None # Highest InvenTree version number that is supported by the plugin +``` + +Refer to the [sample plugins]({{ sourcedir("src/backend/InvenTree/plugin/samples") }}) for further examples. + +### Plugin Config + +A *PluginConfig* database entry will be created for each plugin "discovered" when the server launches. This configuration entry is used to determine if a particular plugin is enabled. + +The configuration entries must be enabled via the [InvenTree admin interface](../settings/admin.md). + +!!! warning "Disabled by Default" + Newly discovered plugins are disabled by default, and must be manually enabled (in the admin interface) by a user with staff privileges. + +## Plugin Mixins + +Common use cases are covered by pre-supplied modules in the form of *mixins* (similar to how [Django]({% include "django.html" %}/topics/class-based-views/mixins/) does it). Each mixin enables the integration into a specific area of InvenTree. Sometimes it also enhances the plugin with helper functions to supply often used functions out-of-the-box. + +Supported mixin classes are: + +| Mixin | Description | +| --- | --- | +| [ActionMixin](./mixins/action.md) | Run custom actions | +| [APICallMixin](./mixins/api.md) | Perform calls to external APIs | +| [AppMixin](./mixins/app.md) | Integrate additional database tables | +| [BarcodeMixin](./mixins/barcode.md) | Support custom barcode actions | +| [CurrencyExchangeMixin](./mixins/currency.md) | Custom interfaces for currency exchange rates | +| [DataExport](./mixins/export.md) | Customize data export functionality | +| [EventMixin](./mixins/event.md) | Respond to events | +| [LabelPrintingMixin](./mixins/label.md) | Custom label printing support | +| [LocateMixin](./mixins/locate.md) | Locate and identify stock items | +| [NavigationMixin](./mixins/navigation.md) | Add custom pages to the web interface | +| [NotificationMixin](./mixins/notification.md) | Send custom notifications in response to system events | +| [ReportMixin](./mixins/report.md) | Add custom context data to reports | +| [ScheduleMixin](./mixins/schedule.md) | Schedule periodic tasks | +| [SettingsMixin](./mixins/settings.md) | Integrate user configurable settings | +| [UserInterfaceMixin](./mixins/ui.md) | Add custom user interface features | +| [UrlsMixin](./mixins/urls.md) | Respond to custom URL endpoints | +| [ValidationMixin](./mixins/validation.md) | Provide custom validation of database models | + +## Plugin Concepts + +### Backend vs Frontend Code + +InvenTree plugins can contain both backend and frontend code. The backend code is written in Python, and is used to implement server-side functionality, such as database models, API endpoints, and background tasks. + +The frontend code is written in JavaScript (or TypeScript), and is used to implement user interface components, such as custom UI panels. + +You can [read more about frontend integration](./frontend.md) to learn how to integrate custom UI components into the InvenTree web interface. + +## Static Files + +If your plugin requires static files (e.g. CSS, JavaScript, images), these should be placed in the top level `static` directory within the distributed plugin package. These files will be automatically collected by InvenTree when the plugin is installed, and copied to an appropriate location. + +These files will be available to the InvenTree web interface, and can be accessed via the URL `/static/plugins//`. Static files are served by the [proxy server](../start/processes.md#proxy-server). + +For example, if the plugin is named `my_plugin`, and contains a file `CustomPanel.js`, it can be accessed via the URL `/static/plugins/my_plugin/CustomPanel.js`. + +### Packaging !!! tip "Package-Discovery can be tricky" Most problems with packaging stem from problems with discovery. [This guide](https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#automatic-discovery) by the PyPA contains a lot of information about discovery during packaging. These mechanisms generally apply to most discovery processes in InvenTree and the wider Django ecosystem. diff --git a/docs/docs/plugins/index.md b/docs/docs/plugins/index.md index 59cff8c2ca..ee31efbc3d 100644 --- a/docs/docs/plugins/index.md +++ b/docs/docs/plugins/index.md @@ -10,7 +10,7 @@ Plugins can be added from multiple sources: - Plugins can be installed using the PIP Python package manager - Plugins can be placed in the external [plugins directory](../start/config.md#plugin-options) -- InvenTree built-in plugins are located within the InvenTree source code +- InvenTree [built-in](./builtin/index.md) plugins are located within the InvenTree source code For further information, read more about [installing plugins](./install.md). @@ -18,123 +18,45 @@ For further information, read more about [installing plugins](./install.md). Plugin behaviour can be controlled via the InvenTree configuration options. Refer to the [configuration guide](../start/config.md#plugin-options) for the available plugin configuration options. -### Backend vs Frontend Code +## Developing a Plugin -InvenTree plugins can contain both backend and frontend code. The backend code is written in Python, and is used to implement server-side functionality, such as database models, API endpoints, and background tasks. +If you are interested in developing a custom plugin for InvenTree, refer to the [plugin development guide](./develop.md). This guide provides an overview of the plugin architecture, and how to create a new plugin. -The frontend code is written in JavaScript (or TypeScript), and is used to implement user interface components, such as custom UI panels. - -You can [read more about frontend integration](./frontend.md) to learn how to integrate custom UI components into the InvenTree web interface. - -### Creating a Plugin +### Plugin Creator To assist in creating a new plugin, we provide a [plugin creator command line tool](./creator.md). This allows developers to quickly scaffold a new InvenTree plugin, and provides a basic structure to build upon. +### Plugin Walkthrough -### Basic Plugin Walkthrough +Check out our [plugin development walkthrough](./walkthrough.md) to learn how to create an example plugin. This guide will take you through the steps to add a new part panel that displays an image carousel from images attached to the selected part. -Check out our [basic plugin walkthrough](../plugins/walkthrough.md) to learn how to create an example plugin. This guide will take you through the steps to add a new part panel that displays an image carousel from images attached to the selected part. +## Available Plugins -## Plugin Code Structure +InvenTree plugins can be provided from a variety of sources, including built-in plugins, sample plugins, mandatory plugins, and third-party plugins. -### Plugin Base Class +### Built-in Plugins -Custom plugins must inherit from the [InvenTreePlugin class]({{ sourcefile("src/backend/InvenTree/plugin/plugin.py") }}). Any plugins installed via the methods outlined above will be "discovered" when the InvenTree server launches. +InvenTree comes with a number of built-in plugins that provide additional functionality. These plugins are included in the InvenTree source code, and can be enabled or disabled via the configuration options. -### Imports +Refer to the [built-in plugins documentation](./builtin/index.md) for more information on the available built-in plugins. -As the code base is evolving import paths might change. Therefore we provide stable import targets for important python APIs. -Please read all release notes and watch out for warnings - we generally provide backports for depreciated interfaces for at least one minor release. +### Sample Plugins -#### Plugins +If the InvenTree server is running in [debug mode](../start/config.md#debug-mode), an additional set of *sample* plugins are available. These plugins are intended to demonstrate some of the available capabilities provided by the InvenTree plugin architecture, and can be used as a starting point for developing your own plugins. -General classes and mechanisms are provided under the `plugin` [namespaces]({{ sourcefile("src/backend/InvenTree/plugin/__init__.py") }}). These include: +!!! info "Debug Mode Only" + Sample plugins are only available when the InvenTree server is running in debug mode. This is typically used during development, and is not recommended for production environments. -```python -# Management objects -registry # Object that manages all plugin states and integrations +### Third Party Plugins -# Base classes -InvenTreePlugin # Base class for all plugins +A list of known third-party InvenTree extensions is provided [on our website](https://inventree.org/extend/integrate/) If you have an extension that should be listed here, contact the InvenTree team on [GitHub](https://github.com/inventree/). Refer to the [InvenTree website](https://inventree.org/plugins.html) for a (non exhaustive) list of plugins that are available for InvenTree. This includes both official and third-party plugins. -# Errors -MixinImplementationError # Is raised if a mixin is implemented wrong (default not overwritten for example) -MixinNotImplementedError # Is raised if a mixin was not implemented (core mechanisms are missing from the plugin) -``` +## Mandatory Plugins -#### Mixins +Some plugins are mandatory for InvenTree to function correctly. These plugins are included in the InvenTree source code, and cannot be disabled. They provide essential functionality that is required for the core InvenTree features to work. -Plugin functionality is split between multiple "mixin" classes - each of which provides a specific set of features or behaviors that can be integrated into a plugin. These mixins are designed to be used in conjunction with the `InvenTreePlugin` base class, allowing developers to easily extend the functionality of their plugins. All public APIs that should be used are exposed under `plugin.mixins`. These include all built-in mixins and notification methods. An up-to-date reference can be found in the source code [can be found here]({{ sourcefile("src/backend/InvenTree/plugin/mixins/__init__.py") }}). +### Mandatory Third-Party Plugins -Refer to the [mixin documentation](#plugin-mixins) for a list of available mixins, and their usage. +It may be desirable to mark a third-party plugin as mandatory, meaning that once installed, it is automatically enabled and cannot be disabled. This is useful in situations where a particular plugin is required for crucial functionality and it it imperative that it cannot be disabled by user interaction. -#### Models and other internal InvenTree APIs - -!!! warning "Danger Zone" - The APIs outside of the `plugin` namespace are not structured for public usage and require a more in-depth knowledge of the Django framework. Please ask in GitHub discussions of the `InvenTree` org if you are not sure you are using something the intended way. - -We do not provide stable interfaces to models or any other internal python APIs. If you need to integrate into these parts please make yourself familiar with the codebase. We follow general Django patterns and only stray from them in limited, special cases. -If you need to react to state changes please use the [EventMixin](./mixins/event.md). - -### Plugin Options - -Some metadata options can be defined as constants in the plugins class. - -``` python -NAME = '' # Used as a general reference to the plugin -SLUG = None # Used in URLs, setting-names etc. when a unique slug as a reference is needed -> the plugin name is used if not set -TITLE = None # A nice human friendly name for the plugin -> used in titles, as plugin name etc. - -AUTHOR = None # Author of the plugin, git commit information is used if not present -PUBLISH_DATE = None # Publishing date of the plugin, git commit information is used if not present -WEBSITE = None # Website for the plugin, developer etc. -> is shown in plugin overview if set - -VERSION = None # Version of the plugin -MIN_VERSION = None # Lowest InvenTree version number that is supported by the plugin -MAX_VERSION = None # Highest InvenTree version number that is supported by the plugin -``` - -Refer to the [sample plugins]({{ sourcedir("src/backend/InvenTree/plugin/samples") }}) for further examples. - -### Plugin Config - -A *PluginConfig* database entry will be created for each plugin "discovered" when the server launches. This configuration entry is used to determine if a particular plugin is enabled. - -The configuration entries must be enabled via the [InvenTree admin interface](../settings/admin.md). - -!!! warning "Disabled by Default" - Newly discovered plugins are disabled by default, and must be manually enabled (in the admin interface) by a user with staff privileges. - -## Plugin Mixins - -Common use cases are covered by pre-supplied modules in the form of *mixins* (similar to how [Django]({% include "django.html" %}/topics/class-based-views/mixins/) does it). Each mixin enables the integration into a specific area of InvenTree. Sometimes it also enhances the plugin with helper functions to supply often used functions out-of-the-box. - -Supported mixin classes are: - -| Mixin | Description | -| --- | --- | -| [ActionMixin](./mixins/action.md) | Run custom actions | -| [APICallMixin](./mixins/api.md) | Perform calls to external APIs | -| [AppMixin](./mixins/app.md) | Integrate additional database tables | -| [BarcodeMixin](./mixins/barcode.md) | Support custom barcode actions | -| [CurrencyExchangeMixin](./mixins/currency.md) | Custom interfaces for currency exchange rates | -| [DataExport](./mixins/export.md) | Customize data export functionality | -| [EventMixin](./mixins/event.md) | Respond to events | -| [LabelPrintingMixin](./mixins/label.md) | Custom label printing support | -| [LocateMixin](./mixins/locate.md) | Locate and identify stock items | -| [NavigationMixin](./mixins/navigation.md) | Add custom pages to the web interface | -| [NotificationMixin](./mixins/notification.md) | Send custom notifications in response to system events | -| [ReportMixin](./mixins/report.md) | Add custom context data to reports | -| [ScheduleMixin](./mixins/schedule.md) | Schedule periodic tasks | -| [SettingsMixin](./mixins/settings.md) | Integrate user configurable settings | -| [UserInterfaceMixin](./mixins/ui.md) | Add custom user interface features | -| [UrlsMixin](./mixins/urls.md) | Respond to custom URL endpoints | -| [ValidationMixin](./mixins/validation.md) | Provide custom validation of database models | - -## Static Files - -If your plugin requires static files (e.g. CSS, JavaScript, images), these should be placed in the top level `static` directory within the distributed plugin package. These files will be automatically collected by InvenTree when the plugin is installed, and copied to an appropriate location. - -These files will be available to the InvenTree web interface, and can be accessed via the URL `/static/plugins//`. Static files are served by the [proxy server](../start/processes.md#proxy-server). - -For example, if the plugin is named `my_plugin`, and contains a file `CustomPanel.js`, it can be accessed via the URL `/static/plugins/my_plugin/CustomPanel.js`. +In such as case, the plugin(s) should be marked as "mandatory" at run-time in the [configuration file](../start/config.md#plugin-options). This will ensure that these plugins are always enabled, and cannot be disabled by the user. diff --git a/docs/docs/plugins/integrate.md b/docs/docs/plugins/integrate.md deleted file mode 100644 index 6e1bb1442a..0000000000 --- a/docs/docs/plugins/integrate.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Third Party Integrations ---- - -## Third Party Integrations - -A list of known third-party InvenTree extensions is provided [on our website](https://inventree.org/extend/integrate/) If you have an extension that should be listed here, contact the InvenTree team on [GitHub](https://github.com/inventree/). - -## Available Plugins - -Refer to the [InvenTree website](https://inventree.org/plugins.html) for a (non exhaustive) list of plugins that are available for InvenTree. This includes both official and third-party plugins. diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 5a1395850e..e2c2f6d4de 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -463,6 +463,9 @@ The following [plugin](../plugins/index.md) configuration options are available: | INVENTREE_PLUGIN_NOINSTALL | plugin_noinstall | Disable Plugin installation via API - only use plugins.txt file | False | | INVENTREE_PLUGIN_FILE | plugins_plugin_file | Location of plugin installation file | *Not specified* | | INVENTREE_PLUGIN_DIR | plugins_plugin_dir | Location of external plugin directory | *Not specified* | +| INVENTREE_PLUGINS_MANDATORY | plugins_mandatory | List of [plugins which are considered mandatory](../plugins/index.md#mandatory-third-party-plugins) | *Not specified* | +| INVENTREE_PLUGIN_DEV_SLUG | plugin_dev.slug | Specify plugin to run in [development mode](../plugins/creator.md#backend-configuration) | *Not specified* | +| INVENTREE_PLUGIN_DEV_HOST | plugin_dev.host | Specify host for development mode plugin | http://localhost:5174 | ## Override Global Settings diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 3a63c39008..fa62adcedf 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -210,7 +210,7 @@ nav: - Plugins: - Overview: plugins/index.md - Installation: plugins/install.md - - Developing a Plugin: plugins/how_to.md + - Developing a Plugin: plugins/develop.md - Frontend Integration: plugins/frontend.md - Plugin Creator: plugins/creator.md - Plugin Walkthrough: plugins/walkthrough.md @@ -265,7 +265,6 @@ nav: - Slack Notifications: plugins/builtin/slack_notification.md - UI Notifications: plugins/builtin/ui_notification.md - Currency Exchange: plugins/builtin/currency_exchange.md - - Third-Party: plugins/integrate.md # Plugins plugins: diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index b09cf538e7..afcaa6422f 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -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, diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 03cfbcb794..524956d788 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -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 ] diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 1c53aff9c0..ae7f13dc1b 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -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 ) diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 72aa28fe1f..5b4abcefd9 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -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) diff --git a/src/backend/InvenTree/InvenTree/test_tasks.py b/src/backend/InvenTree/InvenTree/test_tasks.py index b94b266c32..c727c50731 100644 --- a/src/backend/InvenTree/InvenTree/test_tasks.py +++ b/src/backend/InvenTree/InvenTree/test_tasks.py @@ -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): diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index 589aeb5745..4b202d264b 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -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) diff --git a/src/backend/InvenTree/build/test_build.py b/src/backend/InvenTree/build/test_build.py index 323132a6a8..0367f77e7f 100644 --- a/src/backend/InvenTree/build/test_build.py +++ b/src/backend/InvenTree/build/test_build.py @@ -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() diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 95917822ea..779eb93a87 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -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']: diff --git a/src/backend/InvenTree/machine/registry.py b/src/backend/InvenTree/machine/registry.py index af4a61b992..23affee311 100644 --- a/src/backend/InvenTree/machine/registry.py +++ b/src/backend/InvenTree/machine/registry.py @@ -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 = {} diff --git a/src/backend/InvenTree/machine/test_api.py b/src/backend/InvenTree/machine/test_api.py index c64d0ed774..38c1e4f021 100644 --- a/src/backend/InvenTree/machine/test_api.py +++ b/src/backend/InvenTree/machine/test_api.py @@ -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')], diff --git a/src/backend/InvenTree/order/test_sales_order.py b/src/backend/InvenTree/order/test_sales_order.py index 19c24d47dc..7b70b3c1e7 100644 --- a/src/backend/InvenTree/order/test_sales_order.py +++ b/src/backend/InvenTree/order/test_sales_order.py @@ -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') diff --git a/src/backend/InvenTree/order/tests.py b/src/backend/InvenTree/order/tests.py index 5a56382d11..adfb2eebf4 100644 --- a/src/backend/InvenTree/order/tests.py +++ b/src/backend/InvenTree/order/tests.py @@ -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 diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index 2491eafc9f..f5c4c8acaa 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -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' diff --git a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py index 152f95887c..140b0c8c84 100644 --- a/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ScheduleMixin.py @@ -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() diff --git a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py index d8baf529c7..e0ee950859 100644 --- a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py +++ b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py @@ -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() diff --git a/src/backend/InvenTree/plugin/broken/bad_actor.py b/src/backend/InvenTree/plugin/broken/bad_actor.py new file mode 100644 index 0000000000..1cb0470f75 --- /dev/null +++ b/src/backend/InvenTree/plugin/broken/bad_actor.py @@ -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' diff --git a/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py b/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py index 66064d768d..4d6ad741aa 100644 --- a/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py +++ b/src/backend/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py @@ -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.""" diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index b669b7ecae..ef27b91f78 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -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')) diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index bbe5559338..69538cc104 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -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: diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index 660d4d2fde..8245c864d0 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -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. diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 85726f15bc..e6faed6066 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -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: diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index 518e8687a4..813aebbaab 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -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 diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 6d61340482..25d49e4fb5 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -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) diff --git a/src/backend/InvenTree/plugin/test_plugin.py b/src/backend/InvenTree/plugin/test_plugin.py index b8ae982e45..4f5d0dfa51 100644 --- a/src/backend/InvenTree/plugin/test_plugin.py +++ b/src/backend/InvenTree/plugin/test_plugin.py @@ -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()) diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index d80240ba16..7e9311f80e 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -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') diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index 9e83b2d01f..c709dce53f 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -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 diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 6693f3c57e..a134f5346c 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -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() diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index eeafe96df8..f73c704958 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -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, diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts index 3e3938d9c2..1a7d0ea21f 100644 --- a/src/frontend/tests/pui_plugins.spec.ts +++ b/src/frontend/tests/pui_plugins.spec.ts @@ -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 }) => {