2
0
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:
Oliver
2025-07-31 08:26:24 +10:00
committed by GitHub
parent b89a7c45d6
commit b8ea75b2b4
34 changed files with 993 additions and 255 deletions

View File

@@ -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/<plugin_name>/<filename>`. 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.

View File

@@ -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/<plugin_name>/<filename>`. 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -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
]

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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']:

View File

@@ -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 = {}

View File

@@ -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')],

View File

@@ -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')

View File

@@ -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

View File

@@ -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'

View File

@@ -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()

View File

@@ -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()

View 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'

View File

@@ -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."""

View File

@@ -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'))

View File

@@ -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:

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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())

View File

@@ -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')

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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 }) => {