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