mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-18 01:08:44 +00:00
[Refactor] Notification plugins (#9735)
* Refactor notification concept - Notifications handled by plugins * Cleanup * Only send email if template provided in context * Logic cleanup * Fix log_error call * Refactor error logging - Ensure plugin slug is correctly attached - Consistent format - Logic fixes * More robust plugin lookup * Refactor calls to tringger_notification * Tweak for build stock notification * Low stock notification refactor - Actually *use* the notification system - Fix for email template * Check stock only when build is issued * Updated documentation * Add PluginUserSetting class - Allows plugins to define per-user settings * Add API endpoints for PluginUserSetting model * Placeholder for user-plugin-settings page * Refactoring frontend code * Placeholder panel * Adds user interface for changing user-specific plugin settings * Tweaks * Remove old model * Update documentation * Playwright tests * Update API version * Fix unit test * Fix removed arg * Fixes for email notifications - Track status of sending notifications - Add helper "activate" method for plugin class - Update unit tests * Fix barcode tests * More unit test fixes * Test fixes * Fix for settings models with extra fields * Enhance unit test * Remove old test file * Check for null target_fnc * Improve DB query efficiency - Provide a flat list of active keys to plugin.is_active - Prevents DB fetching (in certain circumstances) - Add registry.active_plugins() method * Bump query limit up for test - In practice, this API endpoint is ~10 queries * Handle potential errors * Increase query limit for API test * Increase query limit for some tests * Bump API version * Tweak unit test * Tweak unit test * Increased allowed queries * fix user plugin settings * Fix for unit test * Update debug msg * Tweak API * Fix endpoint * Remove "active plugin keys" code * Restore previous behaviour * Fix unit tests * Tweak unit test * Update src/backend/InvenTree/build/tasks.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/InvenTree/plugin/base/integration/NotificationMixin.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Func updates * Format * Add notification settings * Refactor plugin settings groups * Fix func type * Adjust message * Additional unit tests * Additional playwright tests * Additional playwright test --------- Co-authored-by: Matthias Mair <code@mjmair.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
9
docs/docs/plugins/builtin/email_notification.md
Normal file
9
docs/docs/plugins/builtin/email_notification.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
title: Email Notification Plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Notification Plugin
|
||||||
|
|
||||||
|
This plugin provides a mechanism to send email notifications to users when certain events occur in InvenTree. It implements the [NotificationMixin](../mixins/notification.md) mixin class, allowing it to send notifications based on events defined in the InvenTree system.
|
||||||
|
|
||||||
|
Emails are only sent to users who have a registered email address, and who have enabled email notifications in their user profile.
|
||||||
@@ -19,8 +19,8 @@ The following builtin plugins are available in InvenTree:
|
|||||||
| [BOM Exporter](./bom_exporter.md) | Custom [exporter](../mixins/export.md) for BOM data | Yes |
|
| [BOM Exporter](./bom_exporter.md) | Custom [exporter](../mixins/export.md) for BOM data | Yes |
|
||||||
| [Currency Exchange](./currency_exchange.md) | Currency exchange rate plugin | Yes |
|
| [Currency Exchange](./currency_exchange.md) | Currency exchange rate plugin | Yes |
|
||||||
| [DigiKey](./barcode_digikey.md) | DigiKey barcode support | No |
|
| [DigiKey](./barcode_digikey.md) | DigiKey barcode support | No |
|
||||||
|
| [Email Notification](./email_notification.md) | Email notification plugin | Yes |
|
||||||
| [InvenTree Barcode](./inventree_barcode.md) | Internal barcode support | Yes |
|
| [InvenTree Barcode](./inventree_barcode.md) | Internal barcode support | Yes |
|
||||||
| [InvenTree Core Notifications](./notifications.md) | Core notification system | Yes |
|
|
||||||
| [InvenTree Exporter](./inventree_exporter.md) | Custom [exporter](../mixins/export.md) for InvenTree data | Yes |
|
| [InvenTree Exporter](./inventree_exporter.md) | Custom [exporter](../mixins/export.md) for InvenTree data | Yes |
|
||||||
| [Label Printer](./inventree_label.md) | Custom [label](../mixins/label.md) for InvenTree data | Yes |
|
| [Label Printer](./inventree_label.md) | Custom [label](../mixins/label.md) for InvenTree data | Yes |
|
||||||
| [Label Machine](./inventree_label_machine.md) | Custom [label](../mixins/label.md) for InvenTree data | Yes |
|
| [Label Machine](./inventree_label_machine.md) | Custom [label](../mixins/label.md) for InvenTree data | Yes |
|
||||||
@@ -28,8 +28,10 @@ The following builtin plugins are available in InvenTree:
|
|||||||
| [LCSC](./barcode_lcsc.md) | LCSC barcode support | No |
|
| [LCSC](./barcode_lcsc.md) | LCSC barcode support | No |
|
||||||
| [Mouser](./barcode_mouser.md) | Mouser barcode support | No |
|
| [Mouser](./barcode_mouser.md) | Mouser barcode support | No |
|
||||||
| [Parameter Exporter](./part_parameter_exporter.md) | Custom [exporter](../mixins/export.md) for part parameter data | Yes |
|
| [Parameter Exporter](./part_parameter_exporter.md) | Custom [exporter](../mixins/export.md) for part parameter data | Yes |
|
||||||
| [Part Notifications](./part_notifications.md) | Notifications for part changes | No |
|
| [Part Update Notifications](./part_notifications.md) | Notifications for part changes | No |
|
||||||
|
| [Slack Notification](./slack_notification.md) | Slack notification plugin | No |
|
||||||
| [TME](./barcode_tme.md) | TME barcode support | No |
|
| [TME](./barcode_tme.md) | TME barcode support | No |
|
||||||
|
| [UI Notification](./ui_notification.md) | UI notification plugin | Yes |
|
||||||
|
|
||||||
### Plugin Table
|
### Plugin Table
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
title: Notifications Plugin
|
|
||||||
---
|
|
||||||
|
|
||||||
## InvenTree Core Notifications
|
|
||||||
|
|
||||||
The **InvenTree Core Notifications** plugin provides a notification system for InvenTree. It allows users to receive notifications when certain events occur in the system.
|
|
||||||
|
|
||||||
### Activation
|
|
||||||
|
|
||||||
This plugin is a *mandatory* plugin, and is always enabled.
|
|
||||||
|
|
||||||
### Plugin Settings
|
|
||||||
|
|
||||||
The following settings are available for the notifications plugin:
|
|
||||||
|
|
||||||
{{ image("notification_settings.png", base="plugin/builtin", title="Notification Settings") }}
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Configure the plugin to enable the desired notification types. The plugin will then send notifications to users when the specified events occur.
|
|
||||||
11
docs/docs/plugins/builtin/slack_notification.md
Normal file
11
docs/docs/plugins/builtin/slack_notification.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
title: Slack Notification Plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slack Notification Plugin
|
||||||
|
|
||||||
|
This plugin provides a mechanism to send notifications to a Slack channel when certain events occur in InvenTree. It implements the [NotificationMixin](../mixins/notification.md) mixin class, allowing it to send notifications based on events defined in the InvenTree system.
|
||||||
|
|
||||||
|
### API Key
|
||||||
|
|
||||||
|
To use this plugin, you need to provide a Slack API key. This key is used to authenticate the plugin with the Slack API and send messages to the specified channel.
|
||||||
15
docs/docs/plugins/builtin/ui_notification.md
Normal file
15
docs/docs/plugins/builtin/ui_notification.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
title: UI Notification Plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Notification Plugin
|
||||||
|
|
||||||
|
This plugin provides a mechanism to send notifications to users via the InvenTree User Interface (UI). It implements the [NotificationMixin](../mixins/notification.md) mixin class, allowing it to send notifications based on events defined in the InvenTree system.
|
||||||
|
|
||||||
|
## UI Display
|
||||||
|
|
||||||
|
Any notifications which are generated by the InvenTree core system will be sent to users via this plugin. The notifications will be displayed in the UI:
|
||||||
|
|
||||||
|
### Notification Indicator
|
||||||
|
|
||||||
|
A notification indicator will appear in the top right corner of the InvenTree UI, indicating the number of unread notifications.
|
||||||
45
docs/docs/plugins/mixins/notification.md
Normal file
45
docs/docs/plugins/mixins/notification.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Notification Mixin
|
||||||
|
---
|
||||||
|
|
||||||
|
## NotificationMixin
|
||||||
|
|
||||||
|
The `NotificationMixin` class provides a plugin with the ability to send notifications to users when certain events occur in the system.
|
||||||
|
|
||||||
|
Any notification which is generated by the InvenTree core system can be sent to users via a custom plugin which implements this mixin class.
|
||||||
|
|
||||||
|
### send_notification
|
||||||
|
|
||||||
|
The `send_notification` method is used to send a notification to users:
|
||||||
|
|
||||||
|
::: plugin.base.integration.NotificationMixin.NotificationMixin.send_notification
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
extra:
|
||||||
|
show_sources: True
|
||||||
|
|
||||||
|
### filter_targets
|
||||||
|
|
||||||
|
If desired, the plugin can implement the `filter_targets` method to filter the list of users who will receive the notification. This allows for more granular control over which users are notified based on specific criteria.
|
||||||
|
|
||||||
|
::: plugin.base.integration.NotificationMixin.NotificationMixin.filter_targets
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
extra:
|
||||||
|
show_sources: True
|
||||||
|
|
||||||
|
## Built-in Notifications
|
||||||
|
|
||||||
|
The following built-in notifications plugins are available:
|
||||||
|
|
||||||
|
- [UI Notifications](../builtin/ui_notification.md)
|
||||||
|
- [Email Notifications](../builtin/email_notification.md)
|
||||||
|
- [Slack Notifications](../builtin/slack_notification.md)
|
||||||
@@ -9,13 +9,69 @@ The *SettingsMixin* allows the plugin to save and load persistent settings to th
|
|||||||
- Plugin settings are stored against the individual plugin, and thus do not have to be unique
|
- Plugin settings are stored against the individual plugin, and thus do not have to be unique
|
||||||
- Plugin settings are stored using a "key:value" pair
|
- Plugin settings are stored using a "key:value" pair
|
||||||
|
|
||||||
Use the class constant `SETTINGS` for a dict of settings that should be added as global database settings.
|
## Plugin Settings
|
||||||
|
|
||||||
|
Use the class attribute `SETTINGS` for a dict of settings that should be added as global database settings.
|
||||||
|
|
||||||
The dict must be formatted similar to the following sample that shows how to use validator choices and default.
|
The dict must be formatted similar to the following sample that shows how to use validator choices and default.
|
||||||
|
|
||||||
Take a look at the settings defined in `InvenTree.common.models.InvenTreeSetting` for all possible parameters.
|
Take a look at the settings defined in `InvenTree.common.models.InvenTreeSetting` for all possible parameters.
|
||||||
|
|
||||||
### Example Plugin
|
### get_setting
|
||||||
|
|
||||||
|
Use the `get_setting` method to retrieve a setting value based on the provided key.
|
||||||
|
|
||||||
|
::: plugin.base.integration.SettingsMixin.SettingsMixin.get_setting
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
### set_setting
|
||||||
|
|
||||||
|
Use the `set_setting` method to set a value for a specific setting key.
|
||||||
|
|
||||||
|
::: plugin.base.integration.SettingsMixin.SettingsMixin.set_setting
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
## User Settings
|
||||||
|
|
||||||
|
Plugins may also define user-specific settings, which allow users to customize the behavior of the plugin on a per-user basis.
|
||||||
|
|
||||||
|
To add user-specific settings, use the `USER_SETTINGS` class attribute in a similar way to the `SETTINGS` attribute.
|
||||||
|
|
||||||
|
### get_user_setting
|
||||||
|
|
||||||
|
Use the `get_user_setting` method to retrieve a user-specific setting value based on the provided key and user.
|
||||||
|
|
||||||
|
::: plugin.base.integration.SettingsMixin.SettingsMixin.get_user_setting
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
### set_user_setting
|
||||||
|
|
||||||
|
Use the `set_user_setting` method to set a value for a specific user setting key.
|
||||||
|
|
||||||
|
::: plugin.base.integration.SettingsMixin.SettingsMixin.set_user_setting
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
|
## Example Plugin
|
||||||
|
|
||||||
Below is a simple example of how a plugin can implement settings:
|
Below is a simple example of how a plugin can implement settings:
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ nav:
|
|||||||
- Label Printing Mixin: plugins/mixins/label.md
|
- Label Printing Mixin: plugins/mixins/label.md
|
||||||
- Locate Mixin: plugins/mixins/locate.md
|
- Locate Mixin: plugins/mixins/locate.md
|
||||||
- Navigation Mixin: plugins/mixins/navigation.md
|
- Navigation Mixin: plugins/mixins/navigation.md
|
||||||
|
- Notification Mixin: plugins/mixins/notification.md
|
||||||
- Report Mixin: plugins/mixins/report.md
|
- Report Mixin: plugins/mixins/report.md
|
||||||
- Schedule Mixin: plugins/mixins/schedule.md
|
- Schedule Mixin: plugins/mixins/schedule.md
|
||||||
- Settings Mixin: plugins/mixins/settings.md
|
- Settings Mixin: plugins/mixins/settings.md
|
||||||
@@ -238,24 +239,30 @@ nav:
|
|||||||
- Label Printer: plugins/machines/label_printer.md
|
- Label Printer: plugins/machines/label_printer.md
|
||||||
- Builtin Plugins:
|
- Builtin Plugins:
|
||||||
- Builtin Plugins: plugins/builtin/index.md
|
- Builtin Plugins: plugins/builtin/index.md
|
||||||
- Auto Create Builds: plugins/builtin/auto_create_builds.md
|
|
||||||
- Auto Issue: plugins/builtin/auto_issue.md
|
|
||||||
- BOM Exporter: plugins/builtin/bom_exporter.md
|
|
||||||
- Currency Exchange: plugins/builtin/currency_exchange.md
|
|
||||||
- InvenTree Barcode: plugins/builtin/inventree_barcode.md
|
|
||||||
- InvenTree Exporter: plugins/builtin/inventree_exporter.md
|
|
||||||
- Label Printer: plugins/builtin/inventree_label.md
|
|
||||||
- Label Machine: plugins/builtin/inventree_label_machine.md
|
|
||||||
- Label Sheet: plugins/builtin/inventree_label_sheet.md
|
|
||||||
- Notification: plugins/builtin/notifications.md
|
|
||||||
- Parameter Exporter: plugins/builtin/part_parameter_exporter.md
|
|
||||||
- Part Notifications: plugins/builtin/part_notifications.md
|
|
||||||
- Barcode Plugins:
|
- Barcode Plugins:
|
||||||
- Barcode Plugins: plugins/builtin/barcode_index.md
|
- Barcode Plugins: plugins/builtin/barcode_index.md
|
||||||
|
- InvenTree Barcode: plugins/builtin/inventree_barcode.md
|
||||||
- DigiKey Barcode Plugin: plugins/builtin/barcode_digikey.md
|
- DigiKey Barcode Plugin: plugins/builtin/barcode_digikey.md
|
||||||
- LCSC Barcode Plugin: plugins/builtin/barcode_lcsc.md
|
- LCSC Barcode Plugin: plugins/builtin/barcode_lcsc.md
|
||||||
- Mouser Barcode Plugin: plugins/builtin/barcode_mouser.md
|
- Mouser Barcode Plugin: plugins/builtin/barcode_mouser.md
|
||||||
- TME Barcode Plugin: plugins/builtin/barcode_tme.md
|
- TME Barcode Plugin: plugins/builtin/barcode_tme.md
|
||||||
|
- Event Plugins:
|
||||||
|
- Auto Create Builds: plugins/builtin/auto_create_builds.md
|
||||||
|
- Auto Issue: plugins/builtin/auto_issue.md
|
||||||
|
- Part Update Notification: plugins/builtin/part_notifications.md
|
||||||
|
- Export Plugins:
|
||||||
|
- BOM Exporter: plugins/builtin/bom_exporter.md
|
||||||
|
- InvenTree Exporter: plugins/builtin/inventree_exporter.md
|
||||||
|
- Parameter Exporter: plugins/builtin/part_parameter_exporter.md
|
||||||
|
- Label Printing:
|
||||||
|
- Label Printer: plugins/builtin/inventree_label.md
|
||||||
|
- Label Machine: plugins/builtin/inventree_label_machine.md
|
||||||
|
- Label Sheet: plugins/builtin/inventree_label_sheet.md
|
||||||
|
- Notification Plugins:
|
||||||
|
- Email Notifications: plugins/builtin/email_notification.md
|
||||||
|
- 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
|
- Third-Party: plugins/integrate.md
|
||||||
|
|
||||||
# Plugins
|
# Plugins
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ INVENTREE_API_VERSION = 372
|
|||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
v358 -> 2025-06-21 : https://github.com/inventree/InvenTree/pull/9735
|
||||||
|
- Adds PluginUserSetting model (and associated endpoints)
|
||||||
|
- Remove NotificationSetting model (and associated endpoints)
|
||||||
|
|
||||||
v372 -> 2025-07-19 : https://github.com/inventree/InvenTree/pull/10056
|
v372 -> 2025-07-19 : https://github.com/inventree/InvenTree/pull/10056
|
||||||
- Adds BOM validation information to the Part API
|
- Adds BOM validation information to the Part API
|
||||||
@@ -83,7 +86,7 @@ v352 -> 2025-06-18 : https://github.com/inventree/InvenTree/pull/9803
|
|||||||
- Add valid fields to ordering field descriptions
|
- Add valid fields to ordering field descriptions
|
||||||
|
|
||||||
v351 -> 2025-06-18 : https://github.com/inventree/InvenTree/pull/9602
|
v351 -> 2025-06-18 : https://github.com/inventree/InvenTree/pull/9602
|
||||||
- Adds passwort reset API endpoint for admin users
|
- Adds password reset API endpoint for admin users
|
||||||
|
|
||||||
v350 -> 2025-06-17 : https://github.com/inventree/InvenTree/pull/9798
|
v350 -> 2025-06-17 : https://github.com/inventree/InvenTree/pull/9798
|
||||||
- Adds "can_build" field to the part requirements API endpoint
|
- Adds "can_build" field to the part requirements API endpoint
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ class InvenTreeConfig(AppConfig):
|
|||||||
- Cleaning up tasks
|
- Cleaning up tasks
|
||||||
- Starting regular tasks
|
- Starting regular tasks
|
||||||
- Updating exchange rates
|
- Updating exchange rates
|
||||||
- Collecting notification methods
|
|
||||||
- Collecting state transition methods
|
- Collecting state transition methods
|
||||||
- Adding users set in the current environment
|
- Adding users set in the current environment
|
||||||
"""
|
"""
|
||||||
@@ -71,7 +70,6 @@ class InvenTreeConfig(AppConfig):
|
|||||||
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
|
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
|
||||||
|
|
||||||
self.update_site_url()
|
self.update_site_url()
|
||||||
self.collect_notification_methods()
|
|
||||||
self.collect_state_transition_methods()
|
self.collect_state_transition_methods()
|
||||||
|
|
||||||
# Ensure the unit registry is loaded
|
# Ensure the unit registry is loaded
|
||||||
@@ -314,12 +312,6 @@ class InvenTreeConfig(AppConfig):
|
|||||||
# do not try again
|
# do not try again
|
||||||
settings.USER_ADDED_FILE = True
|
settings.USER_ADDED_FILE = True
|
||||||
|
|
||||||
def collect_notification_methods(self):
|
|
||||||
"""Collect all notification methods."""
|
|
||||||
from common.notifications import storage
|
|
||||||
|
|
||||||
storage.collect()
|
|
||||||
|
|
||||||
def collect_state_transition_methods(self):
|
def collect_state_transition_methods(self):
|
||||||
"""Collect all state transition methods."""
|
"""Collect all state transition methods."""
|
||||||
from generic.states import storage
|
from generic.states import storage
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def send_email(
|
|||||||
# If we still don't have a valid from_email, then we can't send emails
|
# If we still don't have a valid from_email, then we can't send emails
|
||||||
if not from_email:
|
if not from_email:
|
||||||
if settings.TESTING:
|
if settings.TESTING:
|
||||||
from_email = 'from@test.com'
|
from_email = 'test@test.inventree.org'
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
'INVE-W7: send_email failed: DEFAULT_FROM_EMAIL not specified'
|
'INVE-W7: send_email failed: DEFAULT_FROM_EMAIL not specified'
|
||||||
|
|||||||
@@ -1199,10 +1199,11 @@ def notify_staff_users_of_error(instance, label: str, context: dict):
|
|||||||
"""Helper function to notify staff users of an error."""
|
"""Helper function to notify staff users of an error."""
|
||||||
import common.models
|
import common.models
|
||||||
import common.notifications
|
import common.notifications
|
||||||
|
from plugin.builtin.integration.core_notifications import InvenTreeUINotifications
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get all staff users
|
# Get all staff users
|
||||||
staff_users = get_user_model().objects.filter(is_staff=True)
|
staff_users = get_user_model().objects.filter(is_active=True, is_staff=True)
|
||||||
|
|
||||||
target_users = []
|
target_users = []
|
||||||
|
|
||||||
@@ -1219,7 +1220,7 @@ def notify_staff_users_of_error(instance, label: str, context: dict):
|
|||||||
label,
|
label,
|
||||||
context=context,
|
context=context,
|
||||||
targets=target_users,
|
targets=target_users,
|
||||||
delivery_methods={common.notifications.UIMessageNotification},
|
delivery_methods={InvenTreeUINotifications},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -365,6 +365,14 @@ class UserSettingsPermissionsOrScope(OASTokenMixin, permissions.BasePermission):
|
|||||||
|
|
||||||
return user == obj.user
|
return user == obj.user
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Check that the requesting user is authenticated."""
|
||||||
|
try:
|
||||||
|
user = request.user
|
||||||
|
return user.is_authenticated
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
def get_required_alternate_scopes(self, request, view):
|
def get_required_alternate_scopes(self, request, view):
|
||||||
"""Return the required scopes for the current request."""
|
"""Return the required scopes for the current request."""
|
||||||
return map_scope(only_read=True)
|
return map_scope(only_read=True)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from django.db import DEFAULT_DB_ALIAS, connections
|
|||||||
from django.db.migrations.executor import MigrationExecutor
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
from django.db.utils import NotSupportedError, OperationalError, ProgrammingError
|
from django.db.utils import NotSupportedError, OperationalError, ProgrammingError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
@@ -470,7 +471,10 @@ def delete_old_notifications():
|
|||||||
def check_for_updates():
|
def check_for_updates():
|
||||||
"""Check if there is an update for InvenTree."""
|
"""Check if there is an update for InvenTree."""
|
||||||
try:
|
try:
|
||||||
from common.notifications import trigger_superuser_notification
|
from common.notifications import trigger_notification
|
||||||
|
from plugin.builtin.integration.core_notifications import (
|
||||||
|
InvenTreeUINotifications,
|
||||||
|
)
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||||
@@ -533,19 +537,16 @@ def check_for_updates():
|
|||||||
|
|
||||||
# Send notification if there is a new version
|
# Send notification if there is a new version
|
||||||
if not isInvenTreeUpToDate():
|
if not isInvenTreeUpToDate():
|
||||||
logger.warning('InvenTree is not up-to-date, sending notification')
|
# Send notification to superusers
|
||||||
|
trigger_notification(
|
||||||
plg = registry.get_plugin('InvenTreeCoreNotificationsPlugin')
|
None,
|
||||||
if not plg:
|
'update_available',
|
||||||
logger.warning('Cannot send notification - plugin not found')
|
targets=get_user_model().objects.filter(is_superuser=True),
|
||||||
return
|
delivery_methods={InvenTreeUINotifications},
|
||||||
plg = plg.plugin_config()
|
context={
|
||||||
if not plg:
|
'name': _('Update Available'),
|
||||||
logger.warning('Cannot send notification - plugin config not found')
|
'message': _('An update for InvenTree is available'),
|
||||||
return
|
},
|
||||||
# Send notification
|
|
||||||
trigger_superuser_notification(
|
|
||||||
plg, f'An update for InvenTree to version {tag} is available'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -641,7 +642,6 @@ def check_for_migrations(force: bool = False, reload_registry: bool = True) -> b
|
|||||||
|
|
||||||
Returns bool indicating if migrations are up to date
|
Returns bool indicating if migrations are up to date
|
||||||
"""
|
"""
|
||||||
from plugin import registry
|
|
||||||
|
|
||||||
def set_pending_migrations(n: int):
|
def set_pending_migrations(n: int):
|
||||||
"""Helper function to inform the user about pending migrations."""
|
"""Helper function to inform the user about pending migrations."""
|
||||||
|
|||||||
@@ -152,11 +152,6 @@ def setting_object(key, *args, **kwargs):
|
|||||||
key, plugin=plg, cache=cache
|
key, plugin=plg, cache=cache
|
||||||
)
|
)
|
||||||
|
|
||||||
elif 'method' in kwargs:
|
|
||||||
return plugin.models.NotificationUserSetting.get_setting_object(
|
|
||||||
key, user=kwargs['user'], method=kwargs['method'], cache=cache
|
|
||||||
)
|
|
||||||
|
|
||||||
elif 'user' in kwargs:
|
elif 'user' in kwargs:
|
||||||
return common.models.InvenTreeUserSetting.get_setting_object(
|
return common.models.InvenTreeUserSetting.get_setting_object(
|
||||||
key, user=kwargs['user'], cache=cache
|
key, user=kwargs['user'], cache=cache
|
||||||
|
|||||||
@@ -592,13 +592,15 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual('InvenTree', data['server'])
|
self.assertEqual('InvenTree', data['server'])
|
||||||
|
|
||||||
# Test with token
|
# Test with token
|
||||||
token = self.get(url=reverse('api-token')).data['token']
|
token = self.get(url=reverse('api-token'), max_query_count=275).data['token']
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
|
||||||
# Anon
|
# Anon
|
||||||
response = self.get(url)
|
response = self.get(url, max_query_count=275)
|
||||||
self.assertEqual(response.json()['database'], None)
|
self.assertEqual(response.json()['database'], None)
|
||||||
|
|
||||||
# Staff
|
# Staff
|
||||||
response = self.get(url, headers={'Authorization': f'Token {token}'})
|
response = self.get(
|
||||||
|
url, headers={'Authorization': f'Token {token}'}, max_query_count=275
|
||||||
|
)
|
||||||
self.assertGreater(len(response.json()['database']), 4)
|
self.assertGreater(len(response.json()['database']), 4)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from django_q.models import Schedule, Task
|
|||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
|
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||||
|
|
||||||
threshold = timezone.now() - timedelta(days=30)
|
threshold = timezone.now() - timedelta(days=30)
|
||||||
threshold_low = threshold - timedelta(days=1)
|
threshold_low = threshold - timedelta(days=1)
|
||||||
@@ -191,7 +191,7 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
|
|
||||||
# Create a staff user (to ensure notifications are sent)
|
# Create a staff user (to ensure notifications are sent)
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username='staff', password='staffpass', is_staff=False
|
username='i_am_staff', password='staffpass', is_staff=False, is_active=True
|
||||||
)
|
)
|
||||||
|
|
||||||
n_tasks = Task.objects.count()
|
n_tasks = Task.objects.count()
|
||||||
@@ -220,6 +220,9 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
user.is_staff = True
|
user.is_staff = True
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
# Ensure error notifications are enabled for this user
|
||||||
|
InvenTreeUserSetting.set_setting('NOTIFICATION_ERROR_REPORT', True, user=user)
|
||||||
|
|
||||||
# Create a 'failed' task in the database
|
# Create a 'failed' task in the database
|
||||||
# Note: The 'attempt count' is set to 10 to ensure that the task is properly marked as 'failed'
|
# Note: The 'attempt count' is set to 10 to ensure that the task is properly marked as 'failed'
|
||||||
Task.objects.create(id=n_tasks + 2, **test_data)
|
Task.objects.create(id=n_tasks + 2, **test_data)
|
||||||
|
|||||||
@@ -677,12 +677,15 @@ class AdminTestCase(InvenTreeAPITestCase):
|
|||||||
app_app, app_mdl = model._meta.app_label, model._meta.model_name
|
app_app, app_mdl = model._meta.app_label, model._meta.model_name
|
||||||
|
|
||||||
# 'Test listing
|
# 'Test listing
|
||||||
response = self.get(reverse(f'admin:{app_app}_{app_mdl}_changelist'))
|
response = self.get(
|
||||||
|
reverse(f'admin:{app_app}_{app_mdl}_changelist'), max_query_count=300
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
# Test change view
|
# Test change view
|
||||||
response = self.get(
|
response = self.get(
|
||||||
reverse(f'admin:{app_app}_{app_mdl}_change', kwargs={'object_id': obj.pk})
|
reverse(f'admin:{app_app}_{app_mdl}_change', kwargs={'object_id': obj.pk}),
|
||||||
|
max_query_count=300,
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'Django site admin')
|
self.assertContains(response, 'Django site admin')
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class BuildEvents(BaseEventEnum):
|
|||||||
COMPLETED = 'build.completed'
|
COMPLETED = 'build.completed'
|
||||||
OVERDUE = 'build.overdue_build_order'
|
OVERDUE = 'build.overdue_build_order'
|
||||||
|
|
||||||
|
STOCK_REQUIRED = 'build.stock_required'
|
||||||
|
|
||||||
# Build output events
|
# Build output events
|
||||||
OUTPUT_CREATED = 'buildoutput.created'
|
OUTPUT_CREATED = 'buildoutput.created'
|
||||||
OUTPUT_COMPLETED = 'buildoutput.completed'
|
OUTPUT_COMPLETED = 'buildoutput.completed'
|
||||||
|
|||||||
@@ -760,6 +760,11 @@ class Build(
|
|||||||
|
|
||||||
trigger_event(BuildEvents.ISSUED, id=self.pk)
|
trigger_event(BuildEvents.ISSUED, id=self.pk)
|
||||||
|
|
||||||
|
from build.tasks import check_build_stock
|
||||||
|
|
||||||
|
# Run checks on required parts
|
||||||
|
InvenTree.tasks.offload_task(check_build_stock, self, group='build')
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def hold_build(self):
|
def hold_build(self):
|
||||||
"""Mark the Build as ON HOLD."""
|
"""Mark the Build as ON HOLD."""
|
||||||
@@ -1504,8 +1509,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
from . import tasks as build_tasks
|
|
||||||
|
|
||||||
if instance:
|
if instance:
|
||||||
if created:
|
if created:
|
||||||
# A new Build has just been created
|
# A new Build has just been created
|
||||||
@@ -1513,11 +1516,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
|||||||
# Generate initial BuildLine objects for the Build
|
# Generate initial BuildLine objects for the Build
|
||||||
instance.create_build_line_items()
|
instance.create_build_line_items()
|
||||||
|
|
||||||
# Run checks on required parts
|
|
||||||
InvenTree.tasks.offload_task(
|
|
||||||
build_tasks.check_build_stock, instance, group='build'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify the responsible users that the build order has been created
|
# Notify the responsible users that the build order has been created
|
||||||
InvenTree.helpers_model.notify_responsible(
|
InvenTree.helpers_model.notify_responsible(
|
||||||
instance,
|
instance,
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ from datetime import timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from allauth.account.models import EmailAddress
|
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
|
|
||||||
import common.notifications
|
import common.notifications
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.helpers_email
|
|
||||||
import InvenTree.helpers_model
|
import InvenTree.helpers_model
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from build.events import BuildEvents
|
from build.events import BuildEvents
|
||||||
@@ -175,34 +172,30 @@ def check_build_stock(build):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Are there any users subscribed to these parts?
|
# Are there any users subscribed to these parts?
|
||||||
subscribers = build.part.get_subscribers()
|
targets = build.part.get_subscribers()
|
||||||
|
|
||||||
emails = EmailAddress.objects.filter(user__in=subscribers)
|
if build.responsible:
|
||||||
|
targets.append(build.responsible)
|
||||||
|
|
||||||
if len(emails) > 0:
|
name = _('Stock required for build order')
|
||||||
logger.info('Notifying users of stock required for build %s', build.pk)
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
'build': build,
|
||||||
build.get_absolute_url()
|
'name': name,
|
||||||
),
|
'part': build.part,
|
||||||
'build': build,
|
'lines': lines,
|
||||||
'part': build.part,
|
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||||
'lines': lines,
|
build.get_absolute_url()
|
||||||
}
|
),
|
||||||
|
'message': _('Build order {build} requires additional stock').format(
|
||||||
|
build=build
|
||||||
|
),
|
||||||
|
'template': {'html': 'email/build_order_required_stock.html', 'subject': name},
|
||||||
|
}
|
||||||
|
|
||||||
# Render the HTML message
|
common.notifications.trigger_notification(
|
||||||
html_message = render_to_string(
|
build, BuildEvents.STOCK_REQUIRED, targets=targets, context=context
|
||||||
'email/build_order_required_stock.html', context
|
)
|
||||||
)
|
|
||||||
|
|
||||||
subject = _('Stock required for build order')
|
|
||||||
|
|
||||||
recipients = emails.values_list('email', flat=True)
|
|
||||||
|
|
||||||
InvenTree.helpers_email.send_email(
|
|
||||||
subject, '', recipients, html_message=html_message
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@tracer.start_as_current_span('notify_overdue_build_order')
|
@tracer.start_as_current_span('notify_overdue_build_order')
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ from InvenTree.permissions import (
|
|||||||
IsSuperuserOrSuperScope,
|
IsSuperuserOrSuperScope,
|
||||||
UserSettingsPermissionsOrScope,
|
UserSettingsPermissionsOrScope,
|
||||||
)
|
)
|
||||||
from plugin.models import NotificationUserSetting
|
|
||||||
from plugin.serializers import NotificationUserSettingSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class CsrfExemptMixin:
|
class CsrfExemptMixin:
|
||||||
@@ -308,36 +306,6 @@ class UserSettingsDetail(RetrieveUpdateAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingsList(SettingsList):
|
|
||||||
"""API endpoint for accessing a list of notification user settings objects."""
|
|
||||||
|
|
||||||
queryset = NotificationUserSetting.objects.all()
|
|
||||||
serializer_class = NotificationUserSettingSerializer
|
|
||||||
permission_classes = [UserSettingsPermissionsOrScope]
|
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
|
||||||
"""Only list settings which apply to the current user."""
|
|
||||||
try:
|
|
||||||
user = self.request.user
|
|
||||||
except AttributeError:
|
|
||||||
return NotificationUserSetting.objects.none()
|
|
||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
|
||||||
queryset = queryset.filter(user=user)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingsDetail(RetrieveUpdateAPI):
|
|
||||||
"""Detail view for an individual "notification user setting" object.
|
|
||||||
|
|
||||||
- User can only view / edit settings their own settings objects
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = NotificationUserSetting.objects.all()
|
|
||||||
serializer_class = NotificationUserSettingSerializer
|
|
||||||
permission_classes = [UserSettingsPermissionsOrScope]
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationMessageMixin:
|
class NotificationMessageMixin:
|
||||||
"""Generic mixin for NotificationMessage."""
|
"""Generic mixin for NotificationMessage."""
|
||||||
|
|
||||||
@@ -962,24 +930,6 @@ settings_api_urls = [
|
|||||||
path('', UserSettingsList.as_view(), name='api-user-setting-list'),
|
path('', UserSettingsList.as_view(), name='api-user-setting-list'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
# Notification settings
|
|
||||||
path(
|
|
||||||
'notification/',
|
|
||||||
include([
|
|
||||||
# Notification Settings Detail
|
|
||||||
path(
|
|
||||||
'<int:pk>/',
|
|
||||||
NotificationUserSettingsDetail.as_view(),
|
|
||||||
name='api-notification-setting-detail',
|
|
||||||
),
|
|
||||||
# Notification Settings List
|
|
||||||
path(
|
|
||||||
'',
|
|
||||||
NotificationUserSettingsList.as_view(),
|
|
||||||
name='api-notification-setting-list',
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Global settings
|
# Global settings
|
||||||
path(
|
path(
|
||||||
'global/',
|
'global/',
|
||||||
|
|||||||
@@ -603,7 +603,15 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
if not setting and create:
|
if not setting and create:
|
||||||
# Attempt to create a new settings object
|
# Attempt to create a new settings object
|
||||||
default_value = cls.get_setting_default(key, **kwargs)
|
default_value = cls.get_setting_default(key, **kwargs)
|
||||||
setting = cls(key=key, value=default_value, **kwargs)
|
|
||||||
|
extra_fields = {}
|
||||||
|
|
||||||
|
# Provide extra default fields
|
||||||
|
for field in cls.extra_unique_fields:
|
||||||
|
if field in kwargs:
|
||||||
|
extra_fields[field] = kwargs[field]
|
||||||
|
|
||||||
|
setting = cls(key=key, value=default_value, **extra_fields)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
# Wrap this statement in "atomic", so it can be rolled back if it fails
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import InvenTree.helpers
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.ready import isImportingData, isRebuildingData
|
from InvenTree.ready import isImportingData, isRebuildingData
|
||||||
from plugin import registry
|
from plugin import PluginMixinEnum, registry
|
||||||
from plugin.models import NotificationUserSetting, PluginConfig
|
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
from users.permissions import check_user_permission
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
@@ -108,194 +107,6 @@ class NotificationMethod:
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
"""Returns targets for notifications.
|
|
||||||
|
|
||||||
Processes `self.targets` to extract all users that should be notified.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError('The `get_targets` method must be implemented!')
|
|
||||||
|
|
||||||
def setup(self):
|
|
||||||
"""Set up context before notifications are send.
|
|
||||||
|
|
||||||
This is intended to be overridden in method implementations.
|
|
||||||
"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Clean up context after all notifications were send.
|
|
||||||
|
|
||||||
This is intended to be overridden in method implementations.
|
|
||||||
"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
# region plugins
|
|
||||||
def get_plugin(self):
|
|
||||||
"""Returns plugin class."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def global_setting_disable(self):
|
|
||||||
"""Check if the method is defined in a plugin and has a global setting."""
|
|
||||||
# Check if plugin has a setting
|
|
||||||
if not self.GLOBAL_SETTING:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if plugin is set
|
|
||||||
plg_cls = self.get_plugin()
|
|
||||||
if not plg_cls:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if method globally enabled
|
|
||||||
plg_instance = registry.get_plugin(plg_cls.NAME.lower())
|
|
||||||
return plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING)
|
|
||||||
|
|
||||||
def usersetting(self, target):
|
|
||||||
"""Returns setting for this method for a given user."""
|
|
||||||
return NotificationUserSetting.get_setting(
|
|
||||||
f'NOTIFICATION_METHOD_{self.METHOD_NAME.upper()}',
|
|
||||||
user=target,
|
|
||||||
method=self.METHOD_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
|
|
||||||
class SingleNotificationMethod(NotificationMethod):
|
|
||||||
"""NotificationMethod that sends notifications one by one."""
|
|
||||||
|
|
||||||
def send(self, target):
|
|
||||||
"""This function must be overridden."""
|
|
||||||
raise NotImplementedError('The `send` method must be overridden!')
|
|
||||||
|
|
||||||
|
|
||||||
class BulkNotificationMethod(NotificationMethod):
|
|
||||||
"""NotificationMethod that sends all notifications in bulk."""
|
|
||||||
|
|
||||||
def send_bulk(self):
|
|
||||||
"""This function must be overridden."""
|
|
||||||
raise NotImplementedError('The `send` method must be overridden!')
|
|
||||||
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
|
|
||||||
|
|
||||||
class MethodStorageClass:
|
|
||||||
"""Class that works as registry for all available notification methods in InvenTree.
|
|
||||||
|
|
||||||
Is initialized on startup as one instance named `storage` in this file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
methods_list = None
|
|
||||||
user_settings = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def methods(self):
|
|
||||||
"""Return all available methods.
|
|
||||||
|
|
||||||
This is cached, and stored internally.
|
|
||||||
"""
|
|
||||||
if self.methods_list is None:
|
|
||||||
self.collect()
|
|
||||||
|
|
||||||
return self.methods_list
|
|
||||||
|
|
||||||
def collect(self, selected_classes=None):
|
|
||||||
"""Collect all classes in the environment that are notification methods.
|
|
||||||
|
|
||||||
Can be filtered to only include provided classes for testing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
selected_classes (class, optional): References to the classes that should be registered. Defaults to None.
|
|
||||||
"""
|
|
||||||
logger.debug('Collecting notification methods...')
|
|
||||||
|
|
||||||
current_method = (
|
|
||||||
InvenTree.helpers.inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
|
|
||||||
)
|
|
||||||
|
|
||||||
# for testing selective loading is made available
|
|
||||||
if selected_classes:
|
|
||||||
current_method = [
|
|
||||||
item for item in current_method if item is selected_classes
|
|
||||||
]
|
|
||||||
|
|
||||||
# make sure only one of each method is added
|
|
||||||
filtered_list = {}
|
|
||||||
for item in current_method:
|
|
||||||
plugin = item.get_plugin(item)
|
|
||||||
ref = (
|
|
||||||
f'{plugin.package_path}_{item.METHOD_NAME}'
|
|
||||||
if plugin
|
|
||||||
else item.METHOD_NAME
|
|
||||||
)
|
|
||||||
item.plugin = plugin() if plugin else None
|
|
||||||
filtered_list[ref] = item
|
|
||||||
|
|
||||||
storage.methods_list = list(filtered_list.values())
|
|
||||||
|
|
||||||
logger.info('Found %s notification methods', len(storage.methods_list))
|
|
||||||
|
|
||||||
for item in storage.methods_list:
|
|
||||||
logger.debug(' - %s', str(item))
|
|
||||||
|
|
||||||
def get_usersettings(self, user) -> list:
|
|
||||||
"""Returns all user settings for a specific user.
|
|
||||||
|
|
||||||
This is needed to show them in the settings UI.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user (User): User that should be used as a filter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: All applicablae notification settings.
|
|
||||||
"""
|
|
||||||
methods = []
|
|
||||||
|
|
||||||
for item in storage.methods:
|
|
||||||
if item.USER_SETTING:
|
|
||||||
new_key = f'NOTIFICATION_METHOD_{item.METHOD_NAME.upper()}'
|
|
||||||
|
|
||||||
# make sure the setting exists
|
|
||||||
self.user_settings[new_key] = item.USER_SETTING
|
|
||||||
NotificationUserSetting.get_setting(
|
|
||||||
key=new_key, user=user, method=item.METHOD_NAME
|
|
||||||
)
|
|
||||||
|
|
||||||
# save definition
|
|
||||||
methods.append({
|
|
||||||
'key': new_key,
|
|
||||||
'icon': getattr(item, 'METHOD_ICON', ''),
|
|
||||||
'method': item.METHOD_NAME,
|
|
||||||
})
|
|
||||||
|
|
||||||
return methods
|
|
||||||
|
|
||||||
|
|
||||||
IGNORED_NOTIFICATION_CLS = {SingleNotificationMethod, BulkNotificationMethod}
|
|
||||||
storage = MethodStorageClass()
|
|
||||||
|
|
||||||
|
|
||||||
class UIMessageNotification(SingleNotificationMethod):
|
|
||||||
"""Delivery method for sending specific users notifications in the notification pain in the web UI."""
|
|
||||||
|
|
||||||
METHOD_NAME = 'ui_message'
|
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
"""Only send notifications for active users."""
|
|
||||||
return [target for target in self.targets if target.is_active]
|
|
||||||
|
|
||||||
def send(self, target):
|
|
||||||
"""Send a UI notification to a user."""
|
|
||||||
common.models.NotificationMessage.objects.create(
|
|
||||||
target_object=self.obj,
|
|
||||||
source_object=target,
|
|
||||||
user=target,
|
|
||||||
category=self.category,
|
|
||||||
name=self.context['name'],
|
|
||||||
message=self.context['message'],
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass()
|
@dataclass()
|
||||||
class NotificationBody:
|
class NotificationBody:
|
||||||
@@ -389,16 +200,16 @@ def trigger_notification(
|
|||||||
obj_ref_value = None
|
obj_ref_value = None
|
||||||
|
|
||||||
# Find the first reference that is available
|
# Find the first reference that is available
|
||||||
for ref in refs:
|
if obj:
|
||||||
if hasattr(obj, ref):
|
for ref in refs:
|
||||||
obj_ref_value = getattr(obj, ref)
|
if hasattr(obj, ref):
|
||||||
break
|
obj_ref_value = getattr(obj, ref)
|
||||||
|
break
|
||||||
|
|
||||||
# Try with some defaults
|
if not obj_ref_value:
|
||||||
if not obj_ref_value:
|
raise KeyError(
|
||||||
raise KeyError(
|
f"Could not resolve an object reference for '{obj!s}' with {','.join(set(refs))}"
|
||||||
f"Could not resolve an object reference for '{obj!s}' with {','.join(set(refs))}"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Check if we have notified recently...
|
# Check if we have notified recently...
|
||||||
delta = timedelta(days=1)
|
delta = timedelta(days=1)
|
||||||
@@ -419,7 +230,7 @@ def trigger_notification(
|
|||||||
target_exclude = set()
|
target_exclude = set()
|
||||||
|
|
||||||
# Collect possible targets
|
# Collect possible targets
|
||||||
if not targets:
|
if not targets and target_fnc:
|
||||||
targets = target_fnc(*target_args, **target_kwargs)
|
targets = target_fnc(*target_args, **target_kwargs)
|
||||||
|
|
||||||
# Convert list of targets to a list of users
|
# Convert list of targets to a list of users
|
||||||
@@ -451,122 +262,45 @@ def trigger_notification(
|
|||||||
'Unknown target passed to trigger_notification method: %s', target
|
'Unknown target passed to trigger_notification method: %s', target
|
||||||
)
|
)
|
||||||
|
|
||||||
if target_users:
|
# Filter out any users who are inactive, or do not have the required model permissions
|
||||||
# Filter out any users who are inactive, or do not have the required model permissions
|
valid_users = list(
|
||||||
valid_users = list(
|
filter(
|
||||||
filter(
|
lambda u: u
|
||||||
lambda u: u and u.is_active and check_user_permission(u, obj, 'view'),
|
and u.is_active
|
||||||
list(target_users),
|
and (not obj or check_user_permission(u, obj, 'view')),
|
||||||
)
|
list(target_users),
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(valid_users) > 0:
|
|
||||||
logger.info(
|
|
||||||
"Sending notification '%s' for '%s' to %s users",
|
|
||||||
category,
|
|
||||||
str(obj),
|
|
||||||
len(valid_users),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collect possible methods
|
|
||||||
if delivery_methods is None:
|
|
||||||
delivery_methods = storage.methods or []
|
|
||||||
else:
|
|
||||||
delivery_methods = delivery_methods - IGNORED_NOTIFICATION_CLS
|
|
||||||
|
|
||||||
for method in delivery_methods:
|
|
||||||
logger.info("Triggering notification method '%s'", method.METHOD_NAME)
|
|
||||||
try:
|
|
||||||
deliver_notification(method, obj, category, valid_users, context)
|
|
||||||
except NotImplementedError as error:
|
|
||||||
# Allow any single notification method to fail, without failing the others
|
|
||||||
logger.error(error)
|
|
||||||
except Exception as error:
|
|
||||||
logger.error(error)
|
|
||||||
|
|
||||||
# Set delivery flag
|
|
||||||
common.models.NotificationEntry.notify(category, obj_ref_value)
|
|
||||||
else:
|
|
||||||
logger.info("No possible users for notification '%s'", category)
|
|
||||||
|
|
||||||
|
|
||||||
def trigger_superuser_notification(plugin: PluginConfig, msg: str):
|
|
||||||
"""Trigger a notification to all superusers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin (PluginConfig): Plugin that is raising the notification
|
|
||||||
msg (str): Detailed message that should be attached
|
|
||||||
"""
|
|
||||||
users = get_user_model().objects.filter(is_superuser=True)
|
|
||||||
|
|
||||||
trigger_notification(
|
|
||||||
plugin,
|
|
||||||
'inventree.plugin',
|
|
||||||
context={'error': plugin, 'name': _('Error raised by plugin'), 'message': msg},
|
|
||||||
targets=users,
|
|
||||||
delivery_methods={UIMessageNotification},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track whether any notifications were sent
|
||||||
|
result = False
|
||||||
|
|
||||||
def deliver_notification(
|
# Send out via all registered notification methods
|
||||||
cls: NotificationMethod, obj: Model, category: str, targets: list, context: dict
|
for plugin in registry.with_mixin(PluginMixinEnum.NOTIFICATION):
|
||||||
):
|
# Skip if the plugin is *not* in the "delivery_methods" list?
|
||||||
"""Send notification with the provided class.
|
match = not delivery_methods
|
||||||
|
|
||||||
Arguments:
|
for notification_class in delivery_methods or []:
|
||||||
cls: The class that should be used to send the notification
|
if type(notification_class) is str:
|
||||||
obj: The object (model instance) that triggered the notification
|
if plugin.slug == notification_class:
|
||||||
category: The category (label) for the notification
|
match = True
|
||||||
targets: List of users that should receive the notification
|
break
|
||||||
context: Context dictionary with additional information for the notification
|
|
||||||
|
|
||||||
- Initializes the method
|
elif getattr(notification_class, 'SLUG', None) == plugin.slug:
|
||||||
- Checks that there are valid targets
|
match = True
|
||||||
- Runs the delivery setup
|
break
|
||||||
- Sends notifications either via `send_bulk` or send`
|
|
||||||
- Runs the delivery cleanup
|
|
||||||
"""
|
|
||||||
# Init delivery method
|
|
||||||
method = cls(obj, category, targets, context)
|
|
||||||
|
|
||||||
if method.targets and len(method.targets) > 0:
|
if not match:
|
||||||
# Log start
|
continue
|
||||||
logger.info(
|
|
||||||
"Notify users via '%s' for notification '%s' for '%s'",
|
|
||||||
method.METHOD_NAME,
|
|
||||||
category,
|
|
||||||
str(obj),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run setup for delivery method
|
try:
|
||||||
method.setup()
|
# Plugin may optionally filter target users
|
||||||
|
filtered_users = plugin.filter_targets(list(valid_users))
|
||||||
|
if plugin.send_notification(obj, category, filtered_users, context):
|
||||||
|
result = True
|
||||||
|
except Exception:
|
||||||
|
log_error('send_notification', plugin=plugin.slug)
|
||||||
|
|
||||||
# Counters for success logs
|
# Log the notification entry
|
||||||
success = True
|
if result:
|
||||||
success_count = 0
|
common.models.NotificationEntry.notify(category, obj_ref_value)
|
||||||
|
|
||||||
# Select delivery method and execute it
|
|
||||||
if hasattr(method, 'send_bulk'):
|
|
||||||
success = method.send_bulk()
|
|
||||||
success_count = len(method.targets)
|
|
||||||
|
|
||||||
elif hasattr(method, 'send'):
|
|
||||||
for target in method.targets:
|
|
||||||
if method.send(target):
|
|
||||||
success_count += 1
|
|
||||||
else:
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Run cleanup for delivery method
|
|
||||||
method.cleanup()
|
|
||||||
|
|
||||||
# Log results
|
|
||||||
logger.info(
|
|
||||||
"Notified %s users via '%s' for notification '%s' for '%s' successfully",
|
|
||||||
success_count,
|
|
||||||
method.METHOD_NAME,
|
|
||||||
category,
|
|
||||||
str(obj),
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
logger.info('There were some problems')
|
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
"""Tests for basic notification methods and functions in InvenTree."""
|
|
||||||
|
|
||||||
from common.notifications import (
|
|
||||||
BulkNotificationMethod,
|
|
||||||
NotificationMethod,
|
|
||||||
SingleNotificationMethod,
|
|
||||||
)
|
|
||||||
from part.test_part import BaseNotificationIntegrationTest
|
|
||||||
|
|
||||||
|
|
||||||
class BaseNotificationTests(BaseNotificationIntegrationTest):
|
|
||||||
"""Tests for basic NotificationMethod."""
|
|
||||||
|
|
||||||
def test_NotificationMethod(self):
|
|
||||||
"""Ensure the implementation requirements are tested."""
|
|
||||||
|
|
||||||
class FalseNotificationMethod(NotificationMethod):
|
|
||||||
METHOD_NAME = 'FalseNotification'
|
|
||||||
|
|
||||||
class AnotherFalseNotificationMethod(NotificationMethod):
|
|
||||||
METHOD_NAME = 'AnotherFalseNotification'
|
|
||||||
|
|
||||||
def send(self):
|
|
||||||
"""A comment so we do not need a pass."""
|
|
||||||
|
|
||||||
class NoNameNotificationMethod(NotificationMethod):
|
|
||||||
def send(self):
|
|
||||||
"""A comment so we do not need a pass."""
|
|
||||||
|
|
||||||
class WrongContextNotificationMethod(NotificationMethod):
|
|
||||||
METHOD_NAME = 'WrongContextNotification'
|
|
||||||
CONTEXT_EXTRA = ['aa', ('aa', 'bb'), ('templates', 'ccc'), (123,)]
|
|
||||||
|
|
||||||
def send(self):
|
|
||||||
"""A comment so we do not need a pass."""
|
|
||||||
|
|
||||||
# no send / send bulk
|
|
||||||
with self.assertRaises(NotImplementedError):
|
|
||||||
FalseNotificationMethod('', '', '', '')
|
|
||||||
|
|
||||||
# no METHOD_NAME
|
|
||||||
with self.assertRaises(NotImplementedError):
|
|
||||||
NoNameNotificationMethod('', '', '', '')
|
|
||||||
|
|
||||||
# a not existent context check
|
|
||||||
with self.assertRaises(NotImplementedError):
|
|
||||||
WrongContextNotificationMethod('', '', '', '')
|
|
||||||
|
|
||||||
# no get_targets
|
|
||||||
with self.assertRaises(NotImplementedError):
|
|
||||||
AnotherFalseNotificationMethod('', '', '', {'name': 1, 'message': 2})
|
|
||||||
|
|
||||||
def test_failing_passing(self):
|
|
||||||
"""Ensure that an error in one deliverymethod is not blocking all mehthods."""
|
|
||||||
# cover failing delivery
|
|
||||||
self._notification_run()
|
|
||||||
|
|
||||||
def test_errors_passing(self):
|
|
||||||
"""Ensure that errors do not kill the whole delivery."""
|
|
||||||
|
|
||||||
class ErrorImplementation(SingleNotificationMethod):
|
|
||||||
METHOD_NAME = 'ErrorImplementation'
|
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
return [1]
|
|
||||||
|
|
||||||
def send(self, target):
|
|
||||||
raise KeyError('This could be any error')
|
|
||||||
|
|
||||||
self._notification_run(ErrorImplementation)
|
|
||||||
|
|
||||||
|
|
||||||
class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
|
||||||
"""Tests for BulkNotificationMethod classes specifically.
|
|
||||||
|
|
||||||
General tests for NotificationMethods are in BaseNotificationTests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_BulkNotificationMethod(self):
|
|
||||||
"""Ensure the implementation requirements are tested.
|
|
||||||
|
|
||||||
MixinNotImplementedError needs to raise if the send_bulk() method is not set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class WrongImplementation(BulkNotificationMethod):
|
|
||||||
METHOD_NAME = 'WrongImplementationBulk'
|
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
return [1]
|
|
||||||
|
|
||||||
with self.assertLogs(logger='inventree', level='ERROR'):
|
|
||||||
self._notification_run(WrongImplementation)
|
|
||||||
|
|
||||||
|
|
||||||
class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
|
||||||
"""Tests for SingleNotificationMethod classes specifically.
|
|
||||||
|
|
||||||
General tests for NotificationMethods are in BaseNotificationTests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_SingleNotificationMethod(self):
|
|
||||||
"""Ensure the implementation requirements are tested.
|
|
||||||
|
|
||||||
MixinNotImplementedError needs to raise if the send() method is not set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class WrongImplementation(SingleNotificationMethod):
|
|
||||||
METHOD_NAME = 'WrongImplementationSingle'
|
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
return [1]
|
|
||||||
|
|
||||||
with self.assertLogs(logger='inventree', level='ERROR'):
|
|
||||||
self._notification_run(WrongImplementation)
|
|
||||||
|
|
||||||
|
|
||||||
# A integration test for notifications is provided in test_part.PartNotificationTest
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingTests(BaseNotificationIntegrationTest):
|
|
||||||
"""Tests for NotificationUserSetting."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Setup for all tests."""
|
|
||||||
super().setUp()
|
|
||||||
self.client.login(username=self.user.username, password='password')
|
|
||||||
|
|
||||||
def test_setting_attributes(self):
|
|
||||||
"""Check notification method plugin methods: usersettings and tags."""
|
|
||||||
|
|
||||||
class SampleImplementation(BulkNotificationMethod):
|
|
||||||
METHOD_NAME = 'test'
|
|
||||||
GLOBAL_SETTING = 'ENABLE_NOTIFICATION_TEST'
|
|
||||||
USER_SETTING = {
|
|
||||||
'name': 'Enable test notifications',
|
|
||||||
'description': 'Allow sending of test for event notifications',
|
|
||||||
'default': True,
|
|
||||||
'validator': bool,
|
|
||||||
'units': 'alpha',
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
return [1]
|
|
||||||
|
|
||||||
def send_bulk(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# run through notification
|
|
||||||
self._notification_run(SampleImplementation)
|
|
||||||
@@ -32,7 +32,6 @@ from InvenTree.unit_test import (
|
|||||||
)
|
)
|
||||||
from part.models import Part, PartParameterTemplate
|
from part.models import Part, PartParameterTemplate
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting
|
|
||||||
|
|
||||||
from .api import WebhookView
|
from .api import WebhookView
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -797,28 +796,6 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
response = self.patch(url, {'value': v}, expected_code=400)
|
response = self.patch(url, {'value': v}, expected_code=400)
|
||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
|
|
||||||
"""Tests for the notification user settings API."""
|
|
||||||
|
|
||||||
def test_api_list(self):
|
|
||||||
"""Test list URL."""
|
|
||||||
url = reverse('api-notification-setting-list')
|
|
||||||
|
|
||||||
self.get(url, expected_code=200)
|
|
||||||
|
|
||||||
def test_setting(self):
|
|
||||||
"""Test the string name for NotificationUserSetting."""
|
|
||||||
NotificationUserSetting.set_setting(
|
|
||||||
'NOTIFICATION_METHOD_MAIL', True, change_user=self.user, user=self.user
|
|
||||||
)
|
|
||||||
test_setting = NotificationUserSetting.get_setting_object(
|
|
||||||
'NOTIFICATION_METHOD_MAIL', user=self.user
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): True'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
||||||
"""Tests for the plugin settings API."""
|
"""Tests for the plugin settings API."""
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def _clean_storage(refs):
|
|||||||
|
|
||||||
|
|
||||||
class TransitionTests(InvenTreeTestCase):
|
class TransitionTests(InvenTreeTestCase):
|
||||||
"""Tests for basic NotificationMethod."""
|
"""Tests for basic TransitionMethod."""
|
||||||
|
|
||||||
def test_class(self):
|
def test_class(self):
|
||||||
"""Ensure that the class itself works."""
|
"""Ensure that the class itself works."""
|
||||||
|
|||||||
@@ -197,7 +197,9 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Create a machine
|
# Create a machine
|
||||||
response = self.post(reverse('api-machine-list'), machine_data)
|
response = self.post(
|
||||||
|
reverse('api-machine-list'), machine_data, max_query_count=400
|
||||||
|
)
|
||||||
self.assertEqual(response.data, {**response.data, **machine_data})
|
self.assertEqual(response.data, {**response.data, **machine_data})
|
||||||
pk = response.data['pk']
|
pk = response.data['pk']
|
||||||
|
|
||||||
@@ -231,13 +233,16 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_machine_detail_settings(self):
|
def test_machine_detail_settings(self):
|
||||||
"""Test machine detail settings API endpoint."""
|
"""Test machine detail settings API endpoint."""
|
||||||
|
# TODO: Investigate why these tests need a higher query limit
|
||||||
|
QUERY_LIMIT = 300
|
||||||
|
|
||||||
machine_setting_url = reverse(
|
machine_setting_url = reverse(
|
||||||
'api-machine-settings-detail',
|
'api-machine-settings-detail',
|
||||||
kwargs={'pk': self.placeholder_uuid, 'config_type': 'M', 'key': 'LOCATION'},
|
kwargs={'pk': self.placeholder_uuid, 'config_type': 'M', 'key': 'LOCATION'},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test machine settings for non-existent machine
|
# Test machine settings for non-existent machine
|
||||||
self.get(machine_setting_url, expected_code=404)
|
self.get(machine_setting_url, expected_code=404, max_query_count=QUERY_LIMIT)
|
||||||
|
|
||||||
# Create a machine
|
# Create a machine
|
||||||
machine = MachineConfig.objects.create(
|
machine = MachineConfig.objects.create(
|
||||||
@@ -257,18 +262,22 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get settings
|
# Get settings
|
||||||
response = self.get(machine_setting_url)
|
response = self.get(machine_setting_url, max_query_count=QUERY_LIMIT)
|
||||||
self.assertEqual(response.data['value'], '')
|
self.assertEqual(response.data['value'], '')
|
||||||
|
|
||||||
response = self.get(driver_setting_url)
|
response = self.get(driver_setting_url, max_query_count=QUERY_LIMIT)
|
||||||
self.assertEqual(response.data['value'], '')
|
self.assertEqual(response.data['value'], '')
|
||||||
|
|
||||||
# Update machine setting
|
# Update machine setting
|
||||||
location = StockLocation.objects.create(name='Test Location')
|
location = StockLocation.objects.create(name='Test Location')
|
||||||
response = self.patch(machine_setting_url, {'value': str(location.pk)})
|
response = self.patch(
|
||||||
|
machine_setting_url,
|
||||||
|
{'value': str(location.pk)},
|
||||||
|
max_query_count=QUERY_LIMIT,
|
||||||
|
)
|
||||||
self.assertEqual(response.data['value'], str(location.pk))
|
self.assertEqual(response.data['value'], str(location.pk))
|
||||||
|
|
||||||
response = self.get(machine_setting_url)
|
response = self.get(machine_setting_url, max_query_count=QUERY_LIMIT)
|
||||||
self.assertEqual(response.data['value'], str(location.pk))
|
self.assertEqual(response.data['value'], str(location.pk))
|
||||||
|
|
||||||
# Update driver setting
|
# Update driver setting
|
||||||
@@ -280,7 +289,7 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Get list of all settings for a machine
|
# Get list of all settings for a machine
|
||||||
settings_url = reverse('api-machine-settings', kwargs={'pk': machine.pk})
|
settings_url = reverse('api-machine-settings', kwargs={'pk': machine.pk})
|
||||||
response = self.get(settings_url)
|
response = self.get(settings_url, max_query_count=QUERY_LIMIT)
|
||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[('M', 'LOCATION'), ('D', 'TEST_SETTING')],
|
[('M', 'LOCATION'), ('D', 'TEST_SETTING')],
|
||||||
|
|||||||
@@ -157,14 +157,13 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase):
|
|||||||
self.assertEqual(registry.get_drivers('testing-type')[0].SLUG, 'test-driver')
|
self.assertEqual(registry.get_drivers('testing-type')[0].SLUG, 'test-driver')
|
||||||
|
|
||||||
# test that init hooks where called correctly
|
# test that init hooks where called correctly
|
||||||
self.driver_mocks['init_driver'].assert_called_once()
|
CALL_COUNT = range(1, 5) # Due to interplay between plugin and machine registry
|
||||||
self.assertEqual(self.driver_mocks['init_machine'].call_count, 2)
|
self.assertIn(self.driver_mocks['init_driver'].call_count, CALL_COUNT)
|
||||||
|
self.assertIn(self.driver_mocks['init_machine'].call_count, CALL_COUNT)
|
||||||
|
|
||||||
# Test machine restart hook
|
# Test machine restart hook
|
||||||
registry.restart_machine(self.machine1.machine)
|
registry.restart_machine(self.machine1.machine)
|
||||||
self.driver_mocks['restart_machine'].assert_called_once_with(
|
|
||||||
self.machine1.machine
|
|
||||||
)
|
|
||||||
self.assertEqual(self.machine_mocks[self.machine1]['restart'].call_count, 1)
|
self.assertEqual(self.machine_mocks[self.machine1]['restart'].call_count, 1)
|
||||||
|
|
||||||
# Test machine update hook
|
# Test machine update hook
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ from django.core.cache import cache
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from allauth.account.models import EmailAddress
|
|
||||||
|
|
||||||
import part.settings
|
import part.settings
|
||||||
from common.models import NotificationEntry, NotificationMessage
|
from common.models import NotificationEntry, NotificationMessage
|
||||||
from common.notifications import UIMessageNotification, storage
|
|
||||||
from common.settings import get_global_setting, set_global_setting
|
from common.settings import get_global_setting, set_global_setting
|
||||||
from InvenTree import version
|
from InvenTree import version
|
||||||
from InvenTree.templatetags import inventree_extras
|
from InvenTree.templatetags import inventree_extras
|
||||||
@@ -924,67 +921,39 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
|||||||
self.assertTrue(self.part.is_starred_by(self.user))
|
self.assertTrue(self.part.is_starred_by(self.user))
|
||||||
|
|
||||||
|
|
||||||
class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
class PartNotificationTest(InvenTreeTestCase):
|
||||||
"""Integration test for notifications."""
|
"""Integration test for part notifications."""
|
||||||
|
|
||||||
fixtures = ['location', 'category', 'part', 'stock']
|
fixtures = ['location', 'category', 'part', 'stock']
|
||||||
|
|
||||||
@classmethod
|
def test_low_stock_notification(self):
|
||||||
def setUpTestData(cls):
|
"""Test that a low stocknotification is generated."""
|
||||||
"""Add an email address as part of initialization."""
|
|
||||||
super().setUpTestData()
|
|
||||||
|
|
||||||
# Add email address
|
|
||||||
EmailAddress.objects.create(user=cls.user, email='test@testing.com')
|
|
||||||
|
|
||||||
# Define part that will be tested
|
|
||||||
cls.part = Part.objects.get(name='R_2K2_0805')
|
|
||||||
|
|
||||||
def _notification_run(self, run_class=None):
|
|
||||||
"""Run a notification test suit through.
|
|
||||||
|
|
||||||
If you only want to test one class pass it to run_class
|
|
||||||
"""
|
|
||||||
# reload notification methods
|
|
||||||
storage.collect(run_class)
|
|
||||||
|
|
||||||
NotificationEntry.objects.all().delete()
|
NotificationEntry.objects.all().delete()
|
||||||
|
NotificationMessage.objects.all().delete()
|
||||||
|
|
||||||
# There should be no notification runs
|
part = Part.objects.get(name='R_2K2_0805')
|
||||||
|
|
||||||
|
part.minimum_stock = part.get_stock_count() + 1
|
||||||
|
|
||||||
|
part.save()
|
||||||
|
|
||||||
|
# There should be no notifications created yet,
|
||||||
|
# as there are no "subscribed" users for this part
|
||||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
||||||
|
self.assertEqual(NotificationMessage.objects.all().count(), 0)
|
||||||
|
|
||||||
# Test that notifications run through without errors
|
# Subscribe the user to the part
|
||||||
self.part.minimum_stock = (
|
|
||||||
self.part.get_stock_count() + 1
|
|
||||||
) # make sure minimum is one higher than current count
|
|
||||||
self.part.save()
|
|
||||||
|
|
||||||
# There should be no notification as no-one is subscribed
|
|
||||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
|
||||||
|
|
||||||
# Subscribe and run again
|
|
||||||
addUserPermission(self.user, 'part', 'part', 'view')
|
addUserPermission(self.user, 'part', 'part', 'view')
|
||||||
self.user.is_active = True
|
self.user.is_active = True
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.part.set_starred(self.user, True)
|
part.set_starred(self.user, True)
|
||||||
self.part.save()
|
part.save()
|
||||||
|
|
||||||
# There should be 1 (or 2) notifications - in some cases an error is generated, which creates a subsequent notification
|
# Check that a UI notification entry has been created
|
||||||
self.assertIn(NotificationEntry.objects.all().count(), [1, 2])
|
self.assertGreaterEqual(NotificationEntry.objects.all().count(), 1)
|
||||||
|
self.assertGreaterEqual(NotificationMessage.objects.all().count(), 1)
|
||||||
|
|
||||||
|
# No errors were generated during notification process
|
||||||
|
from error_report.models import Error
|
||||||
|
|
||||||
class PartNotificationTest(BaseNotificationIntegrationTest):
|
self.assertEqual(Error.objects.count(), 0)
|
||||||
"""Integration test for part notifications."""
|
|
||||||
|
|
||||||
def test_notification(self):
|
|
||||||
"""Test that a notification is generated."""
|
|
||||||
self._notification_run(UIMessageNotification)
|
|
||||||
|
|
||||||
# There should be 1 notification message right now
|
|
||||||
self.assertEqual(NotificationMessage.objects.all().count(), 1)
|
|
||||||
|
|
||||||
# Try again -> cover the already send line
|
|
||||||
self.part.save()
|
|
||||||
|
|
||||||
# There should not be more messages
|
|
||||||
self.assertEqual(NotificationMessage.objects.all().count(), 1)
|
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ class PluginSettingInline(admin.TabularInline):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class PluginUserSettingInline(admin.TabularInline):
|
||||||
|
"""Inline admin class for PluginUserSetting."""
|
||||||
|
|
||||||
|
model = models.PluginUserSetting
|
||||||
|
|
||||||
|
read_only_fields = ['key']
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj):
|
||||||
|
"""The plugin user settings should not be meddled with manually."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class PluginConfigAdmin(admin.ModelAdmin):
|
class PluginConfigAdmin(admin.ModelAdmin):
|
||||||
"""Custom admin with restricted id fields."""
|
"""Custom admin with restricted id fields."""
|
||||||
|
|
||||||
@@ -61,21 +73,9 @@ class PluginConfigAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
list_filter = ['active']
|
list_filter = ['active']
|
||||||
actions = [plugin_activate, plugin_deactivate]
|
actions = [plugin_activate, plugin_deactivate]
|
||||||
inlines = [PluginSettingInline]
|
inlines = [PluginSettingInline, PluginUserSettingInline]
|
||||||
exclude = ['metadata']
|
exclude = ['metadata']
|
||||||
|
search_fields = ['name', 'key']
|
||||||
|
|
||||||
class NotificationUserSettingAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin class for NotificationUserSetting."""
|
|
||||||
|
|
||||||
model = models.NotificationUserSetting
|
|
||||||
|
|
||||||
read_only_fields = ['key']
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
"""Notifications should not be changed."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.PluginConfig, PluginConfigAdmin)
|
admin.site.register(models.PluginConfig, PluginConfigAdmin)
|
||||||
admin.site.register(models.NotificationUserSetting, NotificationUserSettingAdmin)
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from plugin.base.action.api import ActionPluginView
|
|||||||
from plugin.base.barcodes.api import barcode_api_urls
|
from plugin.base.barcodes.api import barcode_api_urls
|
||||||
from plugin.base.locate.api import LocatePluginView
|
from plugin.base.locate.api import LocatePluginView
|
||||||
from plugin.base.ui.api import ui_plugins_api_urls
|
from plugin.base.ui.api import ui_plugins_api_urls
|
||||||
from plugin.models import PluginConfig, PluginSetting
|
from plugin.models import PluginConfig, PluginSetting, PluginUserSetting
|
||||||
from plugin.plugin import InvenTreePlugin
|
from plugin.plugin import InvenTreePlugin
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
|
|
||||||
@@ -332,19 +332,19 @@ def check_plugin(
|
|||||||
|
|
||||||
# Check that the 'plugin' specified is valid
|
# Check that the 'plugin' specified is valid
|
||||||
try:
|
try:
|
||||||
plugin_cgf = PluginConfig.objects.filter(**filters).first()
|
plugin_cfg = PluginConfig.objects.filter(**filters).first()
|
||||||
except PluginConfig.DoesNotExist:
|
except PluginConfig.DoesNotExist:
|
||||||
raise NotFound(detail=f"Plugin '{ref}' not installed")
|
raise NotFound(detail=f"Plugin '{ref}' not installed")
|
||||||
|
|
||||||
if plugin_cgf is None:
|
if plugin_cfg is None:
|
||||||
# This only occurs if the plugin mechanism broke
|
# This only occurs if the plugin mechanism broke
|
||||||
raise NotFound(detail=f"Plugin '{ref}' not installed") # pragma: no cover
|
raise NotFound(detail=f"Plugin '{ref}' not installed") # pragma: no cover
|
||||||
|
|
||||||
# Check that the plugin is activated
|
# Check that the plugin is activated
|
||||||
if not plugin_cgf.active:
|
if not plugin_cfg.active:
|
||||||
raise NotFound(detail=f"Plugin '{ref}' is not active")
|
raise NotFound(detail=f"Plugin '{ref}' is not active")
|
||||||
|
|
||||||
plugin = plugin_cgf.plugin
|
plugin = plugin_cfg.plugin
|
||||||
|
|
||||||
if not plugin:
|
if not plugin:
|
||||||
raise NotFound(detail=f"Plugin '{ref}' not installed")
|
raise NotFound(detail=f"Plugin '{ref}' not installed")
|
||||||
@@ -381,10 +381,7 @@ class PluginAllSettingList(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class PluginSettingDetail(RetrieveUpdateAPI):
|
class PluginSettingDetail(RetrieveUpdateAPI):
|
||||||
"""Detail endpoint for a plugin-specific setting.
|
"""Detail endpoint for a plugin-specific setting."""
|
||||||
|
|
||||||
Note that these cannot be created or deleted via the API
|
|
||||||
"""
|
|
||||||
|
|
||||||
queryset = PluginSetting.objects.all()
|
queryset = PluginSetting.objects.all()
|
||||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||||
@@ -415,6 +412,65 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
|||||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||||
|
|
||||||
|
|
||||||
|
class PluginUserSettingList(APIView):
|
||||||
|
"""List endpoint for all user settings for a specific plugin.
|
||||||
|
|
||||||
|
- GET: return all user settings for a plugin config
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PluginUserSetting.objects.all()
|
||||||
|
serializer_class = PluginSerializers.PluginUserSettingSerializer
|
||||||
|
permission_classes = [InvenTree.permissions.UserSettingsPermissionsOrScope]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={200: PluginSerializers.PluginUserSettingSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request, plugin):
|
||||||
|
"""Get all user settings for a plugin config."""
|
||||||
|
# look up the plugin
|
||||||
|
plugin = check_plugin(plugin, None)
|
||||||
|
|
||||||
|
user_settings = getattr(plugin, 'user_settings', {})
|
||||||
|
|
||||||
|
settings_dict = PluginUserSetting.all_settings(
|
||||||
|
settings_definition=user_settings,
|
||||||
|
plugin=plugin.plugin_config(),
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = PluginSerializers.PluginUserSettingSerializer(
|
||||||
|
list(settings_dict.values()), many=True
|
||||||
|
).data
|
||||||
|
return Response(results)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginUserSettingDetail(RetrieveUpdateAPI):
|
||||||
|
"""Detail endpoint for a plugin-specific user setting."""
|
||||||
|
|
||||||
|
lookup_field = 'key'
|
||||||
|
queryset = PluginUserSetting.objects.all()
|
||||||
|
serializer_class = PluginSerializers.PluginUserSettingSerializer
|
||||||
|
permission_classes = [InvenTree.permissions.UserSettingsPermissionsOrScope]
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Lookup the plugin user setting object, based on the URL."""
|
||||||
|
setting_key = self.kwargs['key']
|
||||||
|
|
||||||
|
# Look up plugin
|
||||||
|
plugin = check_plugin(self.kwargs.get('plugin', None), None)
|
||||||
|
|
||||||
|
settings = getattr(plugin, 'user_settings', {})
|
||||||
|
|
||||||
|
if setting_key not in settings:
|
||||||
|
raise NotFound(
|
||||||
|
detail=f"Plugin '{plugin.slug}' has no user setting matching '{setting_key}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return PluginUserSetting.get_setting_object(
|
||||||
|
setting_key, plugin=plugin.plugin_config(), user=self.request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RegistryStatusView(APIView):
|
class RegistryStatusView(APIView):
|
||||||
"""Status API endpoint for the plugin registry.
|
"""Status API endpoint for the plugin registry.
|
||||||
|
|
||||||
@@ -484,6 +540,21 @@ plugin_api_urls = [
|
|||||||
path(
|
path(
|
||||||
'<str:plugin>/',
|
'<str:plugin>/',
|
||||||
include([
|
include([
|
||||||
|
path(
|
||||||
|
'user-settings/',
|
||||||
|
include([
|
||||||
|
re_path(
|
||||||
|
r'^(?P<key>\w+)/',
|
||||||
|
PluginUserSettingDetail.as_view(),
|
||||||
|
name='api-plugin-user-setting-detail',
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
'',
|
||||||
|
PluginUserSettingList.as_view(),
|
||||||
|
name='api-plugin-user-setting-list',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
'settings/',
|
'settings/',
|
||||||
include([
|
include([
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class PluginAppConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
"""The ready method is extended to initialize plugins."""
|
"""The ready method is extended to initialize plugins."""
|
||||||
|
self.reload_plugin_registry()
|
||||||
|
|
||||||
|
def reload_plugin_registry(self):
|
||||||
|
"""Reload the plugin registry."""
|
||||||
if not isInMainThread() and not isInWorkerThread():
|
if not isInMainThread() and not isInWorkerThread():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Plugin mixin class for supporting third-party notification methods."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Model
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from plugin import PluginMixinEnum
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from common.models import SettingsKeyType
|
||||||
|
else:
|
||||||
|
|
||||||
|
class SettingsKeyType:
|
||||||
|
"""Dummy class, so that python throws no error."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationMixin:
|
||||||
|
"""Plugin mixin class for supporting third-party notification methods."""
|
||||||
|
|
||||||
|
class MixinMeta:
|
||||||
|
"""Meta for mixin."""
|
||||||
|
|
||||||
|
MIXIN_NAME = PluginMixinEnum.NOTIFICATION
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Register mixin."""
|
||||||
|
super().__init__()
|
||||||
|
self.add_mixin(PluginMixinEnum.NOTIFICATION, True, __class__)
|
||||||
|
|
||||||
|
def filter_targets(self, targets: list[User]) -> list[User]:
|
||||||
|
"""Filter notification targets based on the plugin's logic."""
|
||||||
|
# Default implementation returns all targets
|
||||||
|
return targets
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
self, target: Model, category: str, users: list, context: dict
|
||||||
|
) -> bool:
|
||||||
|
"""Send notification to the specified target users.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
target (Model): The target model instance to which the notification relates.
|
||||||
|
category (str): The category of the notification.
|
||||||
|
users (list): List of users to send the notification to.
|
||||||
|
context (dict): Context data for the notification.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the notification was sent successfully, False otherwise.
|
||||||
|
"""
|
||||||
|
# The default implementation does nothing
|
||||||
|
return False
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Plugin mixin class for SettingsMixin."""
|
"""Plugin mixin class for SettingsMixin."""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
|
||||||
@@ -12,9 +12,14 @@ logger = structlog.get_logger('inventree')
|
|||||||
|
|
||||||
# import only for typechecking, otherwise this throws a model is unready error
|
# import only for typechecking, otherwise this throws a model is unready error
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from common.models import SettingsKeyType
|
from common.models import SettingsKeyType
|
||||||
else:
|
else:
|
||||||
|
|
||||||
|
class User:
|
||||||
|
"""Dummy class, so that python throws no error."""
|
||||||
|
|
||||||
class SettingsKeyType:
|
class SettingsKeyType:
|
||||||
"""Dummy class, so that python throws no error."""
|
"""Dummy class, so that python throws no error."""
|
||||||
|
|
||||||
@@ -23,6 +28,7 @@ class SettingsMixin:
|
|||||||
"""Mixin that enables global settings for the plugin."""
|
"""Mixin that enables global settings for the plugin."""
|
||||||
|
|
||||||
SETTINGS: dict[str, SettingsKeyType] = {}
|
SETTINGS: dict[str, SettingsKeyType] = {}
|
||||||
|
USER_SETTINGS: dict[str, SettingsKeyType] = {}
|
||||||
|
|
||||||
class MixinMeta:
|
class MixinMeta:
|
||||||
"""Meta for mixin."""
|
"""Meta for mixin."""
|
||||||
@@ -34,6 +40,7 @@ class SettingsMixin:
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin(PluginMixinEnum.SETTINGS, 'has_settings', __class__)
|
self.add_mixin(PluginMixinEnum.SETTINGS, 'has_settings', __class__)
|
||||||
self.settings = getattr(self, 'SETTINGS', {})
|
self.settings = getattr(self, 'SETTINGS', {})
|
||||||
|
self.user_settings = getattr(self, 'USER_SETTINGS', {})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
||||||
@@ -45,12 +52,16 @@ class SettingsMixin:
|
|||||||
logger.debug('Activating plugin settings')
|
logger.debug('Activating plugin settings')
|
||||||
|
|
||||||
registry.mixins_settings = {}
|
registry.mixins_settings = {}
|
||||||
|
registry.mixins_user_settings = {}
|
||||||
|
|
||||||
for slug, plugin in plugins:
|
for slug, plugin in plugins:
|
||||||
if plugin.mixin_enabled(PluginMixinEnum.SETTINGS):
|
if plugin.mixin_enabled(PluginMixinEnum.SETTINGS):
|
||||||
plugin_setting = plugin.settings
|
plugin_setting = plugin.settings or {}
|
||||||
registry.mixins_settings[slug] = plugin_setting
|
registry.mixins_settings[slug] = plugin_setting
|
||||||
|
|
||||||
|
plugin_user_setting = plugin.user_settings or {}
|
||||||
|
registry.mixins_user_settings[slug] = plugin_user_setting
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _deactivate_mixin(cls, registry, **kwargs):
|
def _deactivate_mixin(cls, registry, **kwargs):
|
||||||
"""Deactivate all plugin settings."""
|
"""Deactivate all plugin settings."""
|
||||||
@@ -61,14 +72,16 @@ class SettingsMixin:
|
|||||||
@property
|
@property
|
||||||
def has_settings(self):
|
def has_settings(self):
|
||||||
"""Does this plugin use custom global settings."""
|
"""Does this plugin use custom global settings."""
|
||||||
return bool(self.settings)
|
return bool(self.settings) or bool(self.user_settings)
|
||||||
|
|
||||||
def get_setting(self, key, cache=False, backup_value=None):
|
def get_setting(
|
||||||
|
self, key: str, cache: bool = False, backup_value: Any = None
|
||||||
|
) -> Any:
|
||||||
"""Return the 'value' of the setting associated with this plugin.
|
"""Return the 'value' of the setting associated with this plugin.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
key: The 'name' of the setting value to be retrieved
|
key: The 'name' of the setting value to be retrieved
|
||||||
cache: Whether to use RAM cached value (default = False)
|
cache: Whether to use cached value (default = False)
|
||||||
backup_value: A backup value to return if the setting is not found
|
backup_value: A backup value to return if the setting is not found
|
||||||
"""
|
"""
|
||||||
from plugin.models import PluginSetting
|
from plugin.models import PluginSetting
|
||||||
@@ -77,8 +90,16 @@ class SettingsMixin:
|
|||||||
key, plugin=self.plugin_config(), cache=cache, backup_value=backup_value
|
key, plugin=self.plugin_config(), cache=cache, backup_value=backup_value
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_setting(self, key, value, user=None):
|
def set_setting(
|
||||||
"""Set plugin setting value by key."""
|
self, key: str, value: Any, user: Optional[User] = None, **kwargs
|
||||||
|
) -> None:
|
||||||
|
"""Set plugin setting value by key.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
key: The 'name' of the setting value to be set
|
||||||
|
value: The value to be set for the setting
|
||||||
|
user: The user who is making the change (optional)
|
||||||
|
"""
|
||||||
from plugin.models import PluginSetting
|
from plugin.models import PluginSetting
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
|
|
||||||
@@ -92,7 +113,52 @@ class SettingsMixin:
|
|||||||
logger.error("Plugin configuration not found for plugin '%s'", self.slug)
|
logger.error("Plugin configuration not found for plugin '%s'", self.slug)
|
||||||
return
|
return
|
||||||
|
|
||||||
PluginSetting.set_setting(key, value, user, plugin=plugin)
|
PluginSetting.set_setting(key, value, plugin=plugin)
|
||||||
|
|
||||||
|
def get_user_setting(
|
||||||
|
self, key: str, user: User, cache: bool = False, backup_value: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""Return the 'value' of the user setting associated with this plugin.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
key: The 'name' of the user setting value to be retrieved
|
||||||
|
user: The user for which the setting is to be retrieved
|
||||||
|
cache: Whether to use cached value (default = False)
|
||||||
|
backup_value: A backup value to return if the setting is not found
|
||||||
|
"""
|
||||||
|
from plugin.models import PluginUserSetting
|
||||||
|
|
||||||
|
return PluginUserSetting.get_setting(
|
||||||
|
key,
|
||||||
|
plugin=self.plugin_config(),
|
||||||
|
user=user,
|
||||||
|
cache=cache,
|
||||||
|
backup_value=backup_value,
|
||||||
|
settings=self.user_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_user_setting(self, key: str, value: Any, user: User) -> None:
|
||||||
|
"""Set user setting value by key.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
key: The 'name' of the user setting value to be set
|
||||||
|
value: The value to be set for the user setting
|
||||||
|
user: The user for which the setting is to be set
|
||||||
|
"""
|
||||||
|
from plugin.models import PluginUserSetting
|
||||||
|
from plugin.registry import registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin = registry.get_plugin_config(self.plugin_slug(), self.plugin_name())
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
plugin = None
|
||||||
|
|
||||||
|
if not plugin: # pragma: no cover
|
||||||
|
# Cannot find associated plugin model, return
|
||||||
|
logger.error("Plugin configuration not found for plugin '%s'", self.slug)
|
||||||
|
return
|
||||||
|
|
||||||
|
PluginUserSetting.set_setting(key, value, user, user=user, plugin=plugin)
|
||||||
|
|
||||||
def check_settings(self):
|
def check_settings(self):
|
||||||
"""Check if all required settings for this machine are defined.
|
"""Check if all required settings for this machine are defined.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ def print_label(plugin_slug: str, **kwargs):
|
|||||||
kwargs:
|
kwargs:
|
||||||
passed through to the plugin.print_label() method
|
passed through to the plugin.print_label() method
|
||||||
"""
|
"""
|
||||||
|
from plugin.builtin.integration.core_notifications import InvenTreeUINotifications
|
||||||
|
|
||||||
logger.info("Plugin '%s' is printing a label", plugin_slug)
|
logger.info("Plugin '%s' is printing a label", plugin_slug)
|
||||||
|
|
||||||
plugin = registry.get_plugin(plugin_slug, active=True)
|
plugin = registry.get_plugin(plugin_slug, active=True)
|
||||||
@@ -53,7 +55,7 @@ def print_label(plugin_slug: str, **kwargs):
|
|||||||
'label.printing_failed',
|
'label.printing_failed',
|
||||||
targets=[user],
|
targets=[user],
|
||||||
context=ctx,
|
context=ctx,
|
||||||
delivery_methods={common.notifications.UIMessageNotification},
|
delivery_methods={InvenTreeUINotifications},
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.TESTING:
|
if settings.TESTING:
|
||||||
|
|||||||
@@ -115,11 +115,10 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Plugin is not a label plugin
|
# Plugin is not a label plugin
|
||||||
registry.set_plugin_state('digikeyplugin', True)
|
registry.set_plugin_state('digikeyplugin', True)
|
||||||
no_valid_plg = registry.get_plugin('digikeyplugin').plugin_config()
|
|
||||||
|
|
||||||
response = self.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
{'template': template.pk, 'plugin': no_valid_plg.key, 'items': [1, 2, 3]},
|
{'template': template.pk, 'plugin': 'digikeyplugin', 'items': [1, 2, 3]},
|
||||||
expected_code=400,
|
expected_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +1,169 @@
|
|||||||
"""Core set of Notifications as a Plugin."""
|
"""Core set of Notifications as a Plugin."""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Model
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from allauth.account.models import EmailAddress
|
import structlog
|
||||||
|
|
||||||
import common.models
|
|
||||||
import InvenTree.helpers
|
|
||||||
import InvenTree.helpers_email
|
import InvenTree.helpers_email
|
||||||
import InvenTree.tasks
|
from common.settings import get_global_setting
|
||||||
from plugin import InvenTreePlugin, registry
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
from plugin.mixins import NotificationMixin, SettingsMixin
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class PlgMixin:
|
class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
|
||||||
"""Mixin to access plugin easier.
|
"""Plugin mixin class for supporting UI notification methods."""
|
||||||
|
|
||||||
This needs to be spit out to reference the class. Perks of python.
|
NAME = 'InvenTreeUINotifications'
|
||||||
"""
|
TITLE = _('InvenTree UI Notifications')
|
||||||
|
SLUG = 'inventree-ui-notification'
|
||||||
def get_plugin(self):
|
|
||||||
"""Return plugin reference."""
|
|
||||||
return InvenTreeCoreNotificationsPlugin
|
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeCoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
|
|
||||||
"""Core notification methods for InvenTree."""
|
|
||||||
|
|
||||||
NAME = 'InvenTreeCoreNotificationsPlugin'
|
|
||||||
TITLE = _('InvenTree Notifications')
|
|
||||||
AUTHOR = _('InvenTree contributors')
|
AUTHOR = _('InvenTree contributors')
|
||||||
DESCRIPTION = _('Integrated outgoing notification methods')
|
DESCRIPTION = _('Integrated UI notification methods')
|
||||||
|
VERSION = '1.0.0'
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
self, target: Model, category: str, users: list[User], context: dict
|
||||||
|
) -> bool:
|
||||||
|
"""Create a UI notification entry for specified users."""
|
||||||
|
from common.models import NotificationMessage
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Bulk create notification messages for all provided users
|
||||||
|
for user in users:
|
||||||
|
entries.append(
|
||||||
|
NotificationMessage(
|
||||||
|
target_object=target,
|
||||||
|
source_object=user,
|
||||||
|
user=user,
|
||||||
|
category=category,
|
||||||
|
name=context['name'],
|
||||||
|
message=context['message'],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationMessage.objects.bulk_create(entries)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeEmailNotifications(NotificationMixin, SettingsMixin, InvenTreePlugin):
|
||||||
|
"""Plugin mixin class for supporting email notification methods."""
|
||||||
|
|
||||||
|
NAME = 'InvenTreeEmailNotifications'
|
||||||
|
TITLE = _('InvenTree Email Notifications')
|
||||||
|
SLUG = 'inventree-email-notification'
|
||||||
|
AUTHOR = _('InvenTree contributors')
|
||||||
|
DESCRIPTION = _('Integrated email notification methods')
|
||||||
|
VERSION = '1.0.0'
|
||||||
|
|
||||||
|
USER_SETTINGS = {
|
||||||
|
'NOTIFY_BY_EMAIL': {
|
||||||
|
'name': _('Allow email notifications'),
|
||||||
|
'description': _('Allow email notifications to be sent to this user'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
self, target: Model, category: str, users: list[User], context: dict
|
||||||
|
) -> bool:
|
||||||
|
"""Send notification to the specified targets."""
|
||||||
|
# Ignore if there is no template provided to render
|
||||||
|
if not context.get('template'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
html_message = render_to_string(context['template']['html'], context)
|
||||||
|
|
||||||
|
# Prefix the 'instance title' to the email subject
|
||||||
|
instance_title = get_global_setting('INVENTREE_INSTANCE')
|
||||||
|
subject = context['template'].get('subject', '')
|
||||||
|
|
||||||
|
if instance_title:
|
||||||
|
subject = f'[{instance_title}] {subject}'
|
||||||
|
|
||||||
|
recipients = []
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
# Skip if the user does not want to receive email notifications
|
||||||
|
if not self.get_user_setting('NOTIFY_BY_EMAIL', user, backup_value=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if email := InvenTree.helpers_email.get_email_for_user(user):
|
||||||
|
recipients.append(email)
|
||||||
|
|
||||||
|
if recipients:
|
||||||
|
InvenTree.helpers_email.send_email(
|
||||||
|
subject, '', recipients, html_message=html_message
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No recipients found, so we cannot send the email
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeSlackNotifications(NotificationMixin, SettingsMixin, InvenTreePlugin):
|
||||||
|
"""Plugin mixin class for supporting Slack notification methods."""
|
||||||
|
|
||||||
|
NAME = 'InvenTreeSlackNotifications'
|
||||||
|
TITLE = _('InvenTree Slack Notifications')
|
||||||
|
SLUG = 'inventree-slack-notification'
|
||||||
|
AUTHOR = _('InvenTree contributors')
|
||||||
|
DESCRIPTION = _('Integrated Slack notification methods')
|
||||||
VERSION = '1.0.0'
|
VERSION = '1.0.0'
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'ENABLE_NOTIFICATION_EMAILS': {
|
|
||||||
'name': _('Enable email notifications'),
|
|
||||||
'description': _('Allow sending of emails for event notifications'),
|
|
||||||
'default': False,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'ENABLE_NOTIFICATION_SLACK': {
|
|
||||||
'name': _('Enable slack notifications'),
|
|
||||||
'description': _(
|
|
||||||
'Allow sending of slack channel messages for event notifications'
|
|
||||||
),
|
|
||||||
'default': False,
|
|
||||||
'validator': bool,
|
|
||||||
},
|
|
||||||
'NOTIFICATION_SLACK_URL': {
|
'NOTIFICATION_SLACK_URL': {
|
||||||
'name': _('Slack incoming webhook url'),
|
'name': _('Slack incoming webhook url'),
|
||||||
'description': _('URL that is used to send messages to a slack channel'),
|
'description': _('URL that is used to send messages to a slack channel'),
|
||||||
'protected': True,
|
'protected': True,
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_settings_content(self, request):
|
def send_notification(
|
||||||
"""Custom settings content for the plugin."""
|
self, target: Model, category: str, users: list[User], context: dict
|
||||||
return """
|
) -> bool:
|
||||||
<p>Setup for Slack:</p>
|
"""Send the notifications out via slack."""
|
||||||
<ol>
|
url = self.get_setting('NOTIFICATION_SLACK_URL')
|
||||||
<li>Create a new Slack app on <a href="https://api.slack.com/apps/new" target="_blank">this page</a></li>
|
|
||||||
<li>Enable <i>Incoming Webhooks</i> for the channel you want the notifications posted to</li>
|
|
||||||
<li>Set the webhook URL in the settings above</li>
|
|
||||||
<li>Enable the plugin</li>
|
|
||||||
"""
|
|
||||||
|
|
||||||
class EmailNotification(PlgMixin, BulkNotificationMethod):
|
if not url:
|
||||||
"""Notification method for delivery via Email."""
|
return False
|
||||||
|
|
||||||
METHOD_NAME = 'mail'
|
ret = requests.post(
|
||||||
METHOD_ICON = 'fa-envelope'
|
url,
|
||||||
CONTEXT_EXTRA = [('template',), ('template', 'html'), ('template', 'subject')]
|
json={
|
||||||
GLOBAL_SETTING = 'ENABLE_NOTIFICATION_EMAILS'
|
'text': str(context['message']),
|
||||||
USER_SETTING = {
|
'blocks': [
|
||||||
'name': _('Enable email notifications'),
|
{
|
||||||
'description': _('Allow sending of emails for event notifications'),
|
'type': 'section',
|
||||||
'default': True,
|
'text': {'type': 'plain_text', 'text': str(context['name'])},
|
||||||
'validator': bool,
|
},
|
||||||
}
|
{
|
||||||
|
'type': 'section',
|
||||||
def get_targets(self):
|
'text': {'type': 'mrkdwn', 'text': str(context['message'])},
|
||||||
"""Return a list of target email addresses, only for users which allow email notifications."""
|
'accessory': {
|
||||||
allowed_users = []
|
'type': 'button',
|
||||||
|
|
||||||
for user in self.targets:
|
|
||||||
if not user.is_active:
|
|
||||||
# Ignore any users who have been deactivated
|
|
||||||
continue
|
|
||||||
|
|
||||||
allows_emails = InvenTree.helpers.str2bool(self.usersetting(user))
|
|
||||||
|
|
||||||
if allows_emails:
|
|
||||||
allowed_users.append(user)
|
|
||||||
|
|
||||||
return EmailAddress.objects.filter(user__in=allowed_users)
|
|
||||||
|
|
||||||
def send_bulk(self):
|
|
||||||
"""Send the notifications out via email."""
|
|
||||||
html_message = render_to_string(
|
|
||||||
self.context['template']['html'], self.context
|
|
||||||
)
|
|
||||||
targets = self.targets.values_list('email', flat=True)
|
|
||||||
|
|
||||||
# Prefix the 'instance title' to the email subject
|
|
||||||
instance_title = common.models.InvenTreeSetting.get_setting(
|
|
||||||
'INVENTREE_INSTANCE'
|
|
||||||
)
|
|
||||||
|
|
||||||
subject = self.context['template'].get('subject', '')
|
|
||||||
|
|
||||||
if instance_title:
|
|
||||||
subject = f'[{instance_title}] {subject}'
|
|
||||||
|
|
||||||
InvenTree.helpers_email.send_email(
|
|
||||||
subject, '', targets, html_message=html_message
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
class SlackNotification(PlgMixin, BulkNotificationMethod):
|
|
||||||
"""Notification method for delivery via Slack channel messages."""
|
|
||||||
|
|
||||||
METHOD_NAME = 'slack'
|
|
||||||
METHOD_ICON = 'fa-envelope'
|
|
||||||
GLOBAL_SETTING = 'ENABLE_NOTIFICATION_SLACK'
|
|
||||||
|
|
||||||
def get_targets(self):
|
|
||||||
"""Not used by this method."""
|
|
||||||
return self.targets
|
|
||||||
|
|
||||||
def send_bulk(self):
|
|
||||||
"""Send the notifications out via slack."""
|
|
||||||
instance = registry.get_plugin(self.get_plugin().NAME.lower())
|
|
||||||
url = instance.get_setting('NOTIFICATION_SLACK_URL')
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return False
|
|
||||||
|
|
||||||
ret = requests.post(
|
|
||||||
url,
|
|
||||||
json={
|
|
||||||
'text': str(self.context['message']),
|
|
||||||
'blocks': [
|
|
||||||
{
|
|
||||||
'type': 'section',
|
|
||||||
'text': {
|
'text': {
|
||||||
'type': 'plain_text',
|
'type': 'plain_text',
|
||||||
'text': str(self.context['name']),
|
'text': str(_('Open link')),
|
||||||
|
'emoji': True,
|
||||||
},
|
},
|
||||||
|
'value': f'{category}_{target.pk}' if target else '',
|
||||||
|
'url': context['link'],
|
||||||
|
'action_id': 'button-action',
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
'type': 'section',
|
],
|
||||||
'text': {
|
},
|
||||||
'type': 'mrkdwn',
|
)
|
||||||
'text': str(self.context['message']),
|
|
||||||
},
|
return ret.ok
|
||||||
'accessory': {
|
|
||||||
'type': 'button',
|
|
||||||
'text': {
|
|
||||||
'type': 'plain_text',
|
|
||||||
'text': str(_('Open link')),
|
|
||||||
'emoji': True,
|
|
||||||
},
|
|
||||||
'value': f'{self.category}_{self.obj.pk}',
|
|
||||||
'url': self.context['link'],
|
|
||||||
'action_id': 'button-action',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return ret.ok
|
|
||||||
|
|||||||
@@ -2,35 +2,64 @@
|
|||||||
|
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
|
|
||||||
from part.test_part import BaseNotificationIntegrationTest
|
from common.models import NotificationEntry
|
||||||
|
from common.notifications import trigger_notification
|
||||||
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.builtin.integration.core_notifications import (
|
|
||||||
InvenTreeCoreNotificationsPlugin,
|
|
||||||
)
|
|
||||||
from plugin.models import NotificationUserSetting
|
|
||||||
|
|
||||||
|
|
||||||
class CoreNotificationTestTests(BaseNotificationIntegrationTest):
|
class CoreNotificationTestTests(InvenTreeTestCase):
|
||||||
"""Tests for CoreNotificationsPlugin."""
|
"""Tests for CoreNotificationsPlugin."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up the test case."""
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
# Ensure that the user has an email address set
|
||||||
|
self.user.email = 'test.user@demo.inventree.org'
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
def notify(self):
|
||||||
|
"""Send an email notification."""
|
||||||
|
# Clear all entries to ensure the notification is sent
|
||||||
|
NotificationEntry.objects.all().delete()
|
||||||
|
|
||||||
|
trigger_notification(
|
||||||
|
self.user,
|
||||||
|
'test_notification',
|
||||||
|
targets=[self.user],
|
||||||
|
context={
|
||||||
|
'name': 'Test Email Notification',
|
||||||
|
'message': 'This is a test email notification.',
|
||||||
|
'template': {
|
||||||
|
'html': 'email/test_email.html',
|
||||||
|
'subject': 'Test Email Notification',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_email(self):
|
def test_email(self):
|
||||||
"""Ensure that the email notifications run."""
|
"""Ensure that the email notifications run."""
|
||||||
# No email should be send
|
# No email should be send
|
||||||
self.assertEqual(len(mail.outbox), 0)
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
# enable plugin and set mail setting to true
|
print('- get email plugin:')
|
||||||
plugin = registry.get_plugin('inventreecorenotificationsplugin')
|
|
||||||
plugin.set_setting('ENABLE_NOTIFICATION_EMAILS', True)
|
|
||||||
NotificationUserSetting.set_setting(
|
|
||||||
key='NOTIFICATION_METHOD_MAIL',
|
|
||||||
value=True,
|
|
||||||
change_user=self.user,
|
|
||||||
user=self.user,
|
|
||||||
method=InvenTreeCoreNotificationsPlugin.EmailNotification.METHOD_NAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
# run through
|
plugin = registry.get_plugin('inventree-email-notification')
|
||||||
self._notification_run(InvenTreeCoreNotificationsPlugin.EmailNotification)
|
self.assertIsNotNone(plugin, 'Email notification plugin should be available')
|
||||||
|
|
||||||
|
# First, try with setting disabled
|
||||||
|
plugin.set_user_setting('NOTIFY_BY_EMAIL', False, self.user)
|
||||||
|
|
||||||
|
self.notify()
|
||||||
|
|
||||||
|
# No email should be sent
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
# Now, enable the setting
|
||||||
|
plugin.set_user_setting('NOTIFY_BY_EMAIL', True, self.user)
|
||||||
|
|
||||||
|
self.notify()
|
||||||
|
|
||||||
# Now one mail should be send
|
|
||||||
self.assertEqual(len(mail.outbox), 1)
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_old_mouser_barcode(self):
|
def test_old_mouser_barcode(self):
|
||||||
"""Test old mouser barcode with messed up header."""
|
"""Test old mouser barcode with messed up header."""
|
||||||
|
registry.set_plugin_state('mouserplugin', False)
|
||||||
|
|
||||||
|
# Initial scan should fail - plugin not enabled
|
||||||
|
self.post(
|
||||||
|
self.SCAN_URL, data={'barcode': MOUSER_BARCODE_OLD}, expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.set_plugin_state('mouserplugin', True)
|
||||||
|
|
||||||
result = self.post(
|
result = self.post(
|
||||||
self.SCAN_URL, data={'barcode': MOUSER_BARCODE_OLD}, expected_code=200
|
self.SCAN_URL, data={'barcode': MOUSER_BARCODE_OLD}, expected_code=200
|
||||||
)
|
)
|
||||||
@@ -171,6 +180,11 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
|
|||||||
"""Create supplier part and purchase_order."""
|
"""Create supplier part and purchase_order."""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
|
registry.set_plugin_state('digikeyplugin', True)
|
||||||
|
registry.set_plugin_state('mouserplugin', True)
|
||||||
|
registry.set_plugin_state('lcscplugin', True)
|
||||||
|
registry.set_plugin_state('tmeplugin', True)
|
||||||
|
|
||||||
self.loc_1 = StockLocation.objects.create(name='Location 1')
|
self.loc_1 = StockLocation.objects.create(name='Location 1')
|
||||||
self.loc_2 = StockLocation.objects.create(name='Location 2')
|
self.loc_2 = StockLocation.objects.create(name='Location 2')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Generated by Django 4.2.22 on 2025-06-09 02:03
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("plugin", "0009_alter_pluginconfig_key"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PluginUserSetting",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("key", models.CharField(help_text="Settings key", max_length=50)),
|
||||||
|
(
|
||||||
|
"value",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, help_text="Settings value", max_length=2000
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"plugin",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="user_settings",
|
||||||
|
to="plugin.pluginconfig",
|
||||||
|
verbose_name="Plugin",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="User",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="plugin_settings",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="User",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("plugin", "user", "key")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 4.2.22 on 2025-06-09 06:59
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("plugin", "0010_pluginusersetting"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="NotificationUserSetting",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Utility class to enable simpler imports."""
|
"""Utility class to enable simpler imports."""
|
||||||
|
|
||||||
from common.notifications import BulkNotificationMethod, SingleNotificationMethod
|
|
||||||
from plugin.base.action.mixins import ActionMixin
|
from plugin.base.action.mixins import ActionMixin
|
||||||
from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin
|
from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin
|
||||||
from plugin.base.event.mixins import EventMixin
|
from plugin.base.event.mixins import EventMixin
|
||||||
@@ -10,6 +9,7 @@ from plugin.base.integration.AppMixin import AppMixin
|
|||||||
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
|
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
|
||||||
from plugin.base.integration.DataExport import DataExportMixin
|
from plugin.base.integration.DataExport import DataExportMixin
|
||||||
from plugin.base.integration.NavigationMixin import NavigationMixin
|
from plugin.base.integration.NavigationMixin import NavigationMixin
|
||||||
|
from plugin.base.integration.NotificationMixin import NotificationMixin
|
||||||
from plugin.base.integration.ReportMixin import ReportMixin
|
from plugin.base.integration.ReportMixin import ReportMixin
|
||||||
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
||||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||||
@@ -25,7 +25,6 @@ __all__ = [
|
|||||||
'ActionMixin',
|
'ActionMixin',
|
||||||
'AppMixin',
|
'AppMixin',
|
||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
'BulkNotificationMethod',
|
|
||||||
'CurrencyExchangeMixin',
|
'CurrencyExchangeMixin',
|
||||||
'DataExportMixin',
|
'DataExportMixin',
|
||||||
'EventMixin',
|
'EventMixin',
|
||||||
@@ -34,10 +33,10 @@ __all__ = [
|
|||||||
'LocateMixin',
|
'LocateMixin',
|
||||||
'MailMixin',
|
'MailMixin',
|
||||||
'NavigationMixin',
|
'NavigationMixin',
|
||||||
|
'NotificationMixin',
|
||||||
'ReportMixin',
|
'ReportMixin',
|
||||||
'ScheduleMixin',
|
'ScheduleMixin',
|
||||||
'SettingsMixin',
|
'SettingsMixin',
|
||||||
'SingleNotificationMethod',
|
|
||||||
'SupplierBarcodeMixin',
|
'SupplierBarcodeMixin',
|
||||||
'UrlsMixin',
|
'UrlsMixin',
|
||||||
'UserInterfaceMixin',
|
'UserInterfaceMixin',
|
||||||
|
|||||||
@@ -292,37 +292,60 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
|||||||
return super().get_setting_definition(key, **kwargs)
|
return super().get_setting_definition(key, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class NotificationUserSetting(common.models.BaseInvenTreeSetting):
|
class PluginUserSetting(common.models.BaseInvenTreeSetting):
|
||||||
"""This model represents notification settings for a user."""
|
"""This model represents user-specific settings for individual plugins.
|
||||||
|
|
||||||
typ = 'notification'
|
In contrast with the PluginSetting model, which holds global settings for plugins,
|
||||||
extra_unique_fields = ['method', 'user']
|
this model allows for user-specific settings that can be defined by each user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
typ = 'plugin_user'
|
||||||
|
extra_unique_fields = ['plugin', 'user']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Meta for NotificationUserSetting."""
|
"""Meta for PluginUserSetting."""
|
||||||
|
|
||||||
unique_together = [('method', 'user', 'key')]
|
unique_together = [('plugin', 'user', 'key')]
|
||||||
|
|
||||||
@classmethod
|
plugin = models.ForeignKey(
|
||||||
def get_setting_definition(cls, key, **kwargs):
|
PluginConfig,
|
||||||
"""Override setting_definition to use notification settings."""
|
related_name='user_settings',
|
||||||
from common.notifications import storage
|
null=False,
|
||||||
|
verbose_name=_('Plugin'),
|
||||||
kwargs['settings'] = storage.user_settings
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
return super().get_setting_definition(key, **kwargs)
|
|
||||||
|
|
||||||
method = models.CharField(max_length=255, verbose_name=_('Method'))
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
blank=True,
|
null=False,
|
||||||
null=True,
|
|
||||||
verbose_name=_('User'),
|
verbose_name=_('User'),
|
||||||
help_text=_('User'),
|
help_text=_('User'),
|
||||||
|
related_name='plugin_settings',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Nice name of printing."""
|
"""Nice name of printing."""
|
||||||
return f'{self.key} (for {self.user}): {self.value}'
|
return f'{self.key} (for {self.user}): {self.value}'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_setting_definition(cls, key, **kwargs):
|
||||||
|
"""In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which is a dict object that fully defines all the setting parameters.
|
||||||
|
|
||||||
|
Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings
|
||||||
|
'ahead of time' (as they are defined externally in the plugins).
|
||||||
|
|
||||||
|
Settings can be provided by the caller, as kwargs['settings'].
|
||||||
|
|
||||||
|
If not provided, we'll look at the plugin registry to see what settings are available,
|
||||||
|
(if the plugin is specified!)
|
||||||
|
"""
|
||||||
|
if 'settings' not in kwargs:
|
||||||
|
plugin = kwargs.pop('plugin', None)
|
||||||
|
|
||||||
|
if plugin:
|
||||||
|
mixin_user_settings = getattr(registry, 'mixins_user_settings', None)
|
||||||
|
if mixin_user_settings:
|
||||||
|
kwargs['settings'] = mixin_user_settings.get(plugin.key, {})
|
||||||
|
|
||||||
|
return super().get_setting_definition(key, **kwargs)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class PluginMixinEnum(StringEnum):
|
|||||||
LOCATE = 'locate'
|
LOCATE = 'locate'
|
||||||
MAIL = 'mail'
|
MAIL = 'mail'
|
||||||
NAVIGATION = 'navigation'
|
NAVIGATION = 'navigation'
|
||||||
|
NOTIFICATION = 'notification'
|
||||||
REPORT = 'report'
|
REPORT = 'report'
|
||||||
SCHEDULE = 'schedule'
|
SCHEDULE = 'schedule'
|
||||||
SETTINGS = 'settings'
|
SETTINGS = 'settings'
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ class PluginsRegistry:
|
|||||||
'inventreebarcode',
|
'inventreebarcode',
|
||||||
'bom-exporter',
|
'bom-exporter',
|
||||||
'inventree-exporter',
|
'inventree-exporter',
|
||||||
'inventreecorenotificationsplugin',
|
'inventree-ui-notification',
|
||||||
|
'inventree-email-notification',
|
||||||
'inventreecurrencyexchange',
|
'inventreecurrencyexchange',
|
||||||
'inventreecorenotificationsplugin',
|
|
||||||
'inventreelabel',
|
'inventreelabel',
|
||||||
'inventreelabelmachine',
|
'inventreelabelmachine',
|
||||||
'parameter-exporter',
|
'parameter-exporter',
|
||||||
|
|||||||
@@ -47,6 +47,26 @@ class SampleIntegrationPlugin(
|
|||||||
path('ho/', include(he_urls), name='ho'),
|
path('ho/', include(he_urls), name='ho'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
USER_SETTINGS = {
|
||||||
|
'USER_SETTING_1': {
|
||||||
|
'name': _('User Setting 1'),
|
||||||
|
'description': _('A user setting that can be changed by the user'),
|
||||||
|
'default': 'Default Value',
|
||||||
|
},
|
||||||
|
'USER_SETTING_2': {
|
||||||
|
'name': _('User Setting 2'),
|
||||||
|
'description': _('Another user setting'),
|
||||||
|
'default': True,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
'USER_SETTING_3': {
|
||||||
|
'name': _('User Setting 3'),
|
||||||
|
'description': _('A user setting with choices'),
|
||||||
|
'choices': [('X', 'Choice X'), ('Y', 'Choice Y'), ('Z', 'Choice Z')],
|
||||||
|
'default': 'X',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
SETTINGS = {
|
SETTINGS = {
|
||||||
'PO_FUNCTION_ENABLE': {
|
'PO_FUNCTION_ENABLE': {
|
||||||
'name': _('Enable PO'),
|
'name': _('Enable PO'),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from drf_spectacular.utils import extend_schema_field
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from common.serializers import GenericReferencedSettingSerializer
|
from common.serializers import GenericReferencedSettingSerializer
|
||||||
from plugin.models import NotificationUserSetting, PluginConfig, PluginSetting
|
from plugin.models import PluginConfig, PluginSetting, PluginUserSetting
|
||||||
|
|
||||||
|
|
||||||
class MetadataSerializer(serializers.ModelSerializer):
|
class MetadataSerializer(serializers.ModelSerializer):
|
||||||
@@ -275,14 +275,16 @@ class PluginSettingSerializer(GenericReferencedSettingSerializer):
|
|||||||
plugin = serializers.CharField(source='plugin.key', read_only=True)
|
plugin = serializers.CharField(source='plugin.key', read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
|
class PluginUserSettingSerializer(GenericReferencedSettingSerializer):
|
||||||
"""Serializer for the PluginSetting model."""
|
"""Serializer for the PluginUserSetting model."""
|
||||||
|
|
||||||
MODEL = NotificationUserSetting
|
MODEL = PluginUserSetting
|
||||||
EXTRA_FIELDS = ['method']
|
EXTRA_FIELDS = ['plugin', 'user']
|
||||||
|
|
||||||
method = serializers.CharField(read_only=True)
|
plugin = serializers.CharField(source='plugin.key', read_only=True)
|
||||||
typ = serializers.CharField(read_only=True)
|
user = serializers.PrimaryKeyRelatedField(
|
||||||
|
read_only=True, help_text=_('The user for which this setting applies')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PluginRegistryErrorSerializer(serializers.Serializer):
|
class PluginRegistryErrorSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -315,6 +315,64 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.data['value'], '456')
|
self.assertEqual(response.data['value'], '456')
|
||||||
|
|
||||||
|
def test_plugin_user_settings(self):
|
||||||
|
"""Test the PluginUserSetting API endpoints."""
|
||||||
|
# Fetch user settings for invalid plugin
|
||||||
|
response = self.get(
|
||||||
|
reverse(
|
||||||
|
'api-plugin-user-setting-list', kwargs={'plugin': 'invalid-plugin'}
|
||||||
|
),
|
||||||
|
expected_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch all user settings for the 'email' plugin
|
||||||
|
url = reverse(
|
||||||
|
'api-plugin-user-setting-list',
|
||||||
|
kwargs={'plugin': 'inventree-email-notification'},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
|
||||||
|
settings_keys = [item['key'] for item in response.data]
|
||||||
|
self.assertIn('NOTIFY_BY_EMAIL', settings_keys)
|
||||||
|
|
||||||
|
# Fetch user settings for an invalid key
|
||||||
|
self.get(
|
||||||
|
reverse(
|
||||||
|
'api-plugin-user-setting-detail',
|
||||||
|
kwargs={'plugin': 'inventree-email-notification', 'key': 'INVALID_KEY'},
|
||||||
|
),
|
||||||
|
expected_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch user setting detail for a valid key
|
||||||
|
response = self.get(
|
||||||
|
reverse(
|
||||||
|
'api-plugin-user-setting-detail',
|
||||||
|
kwargs={
|
||||||
|
'plugin': 'inventree-email-notification',
|
||||||
|
'key': 'NOTIFY_BY_EMAIL',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
# User ID must match the current user
|
||||||
|
self.assertEqual(response.data['user'], self.user.pk)
|
||||||
|
|
||||||
|
# Check for expected values
|
||||||
|
for k in [
|
||||||
|
'pk',
|
||||||
|
'key',
|
||||||
|
'value',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'type',
|
||||||
|
'model_name',
|
||||||
|
'user',
|
||||||
|
]:
|
||||||
|
self.assertIn(k, response.data)
|
||||||
|
|
||||||
def test_plugin_metadata(self):
|
def test_plugin_metadata(self):
|
||||||
"""Test metadata endpoint for plugin."""
|
"""Test metadata endpoint for plugin."""
|
||||||
self.user.is_superuser = True
|
self.user.is_superuser = True
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{% endblock title %}
|
{% endblock title %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<tr colspan='100%' style='height: 2rem; text-align: center;'>{% trans "The following parts are low on required stock" %}</tr>
|
<tr colspan='3' style='height: 2rem; text-align: center;'>{% trans "The following parts are low on required stock" %}</tr>
|
||||||
|
|
||||||
<tr style="height: 3rem; border-bottom: 1px solid">
|
<tr style="height: 3rem; border-bottom: 1px solid">
|
||||||
<th>{% trans "Part" %}</th>
|
<th>{% trans "Part" %}</th>
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
<a href='{{ line.link }}'>{{ line.part.full_name }}</a>{% if line.part.description %} - <em>{{ line.part.description }}</em>{% endif %}
|
<a href='{{ line.link }}'>{{ line.part.full_name }}</a>{% if line.part.description %} - <em>{{ line.part.description }}</em>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center;">
|
<td style="text-align: center;">
|
||||||
{% decimal line.required %}
|
{% decimal line.required %} {% if part.units %} [{{ part.units }}]{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center;">{% decimal line.available %}</td>
|
<td style="text-align: center;">{% decimal line.available %} {% if part.units %} [{{ part.units }}]{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
5
src/backend/InvenTree/templates/email/test_email.html
Normal file
5
src/backend/InvenTree/templates/email/test_email.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
This is a simple test email template used to verify that email functionality is working correctly in InvenTree.
|
||||||
@@ -79,7 +79,7 @@ def get_ruleset_models() -> dict:
|
|||||||
# Plugins
|
# Plugins
|
||||||
'plugin_pluginconfig',
|
'plugin_pluginconfig',
|
||||||
'plugin_pluginsetting',
|
'plugin_pluginsetting',
|
||||||
'plugin_notificationusersetting',
|
'plugin_pluginusersetting',
|
||||||
# Misc
|
# Misc
|
||||||
'common_barcodescanresult',
|
'common_barcodescanresult',
|
||||||
'common_newsfeedentry',
|
'common_newsfeedentry',
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export enum ApiEndpoints {
|
|||||||
// Plugin API endpoints
|
// Plugin API endpoints
|
||||||
plugin_list = 'plugins/',
|
plugin_list = 'plugins/',
|
||||||
plugin_setting_list = 'plugins/:plugin/settings/',
|
plugin_setting_list = 'plugins/:plugin/settings/',
|
||||||
|
plugin_user_setting_list = 'plugins/:plugin/user-settings/',
|
||||||
plugin_registry_status = 'plugins/status/',
|
plugin_registry_status = 'plugins/status/',
|
||||||
plugin_install = 'plugins/install/',
|
plugin_install = 'plugins/install/',
|
||||||
plugin_reload = 'plugins/reload/',
|
plugin_reload = 'plugins/reload/',
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ import { t } from '@lingui/core/macro';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
|
|
||||||
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import type { ModelType } from '@lib/enums/ModelType';
|
import type { ModelType } from '@lib/enums/ModelType';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
import type { Setting, SettingsStateProps } from '@lib/types/Settings';
|
import type { Setting, SettingsStateProps } from '@lib/types/Settings';
|
||||||
@@ -25,12 +32,21 @@ import { SettingItem } from './SettingItem';
|
|||||||
export function SettingList({
|
export function SettingList({
|
||||||
settingsState,
|
settingsState,
|
||||||
keys,
|
keys,
|
||||||
onChange
|
onChange,
|
||||||
|
onLoaded
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
settingsState: SettingsStateProps;
|
settingsState: SettingsStateProps;
|
||||||
keys?: string[];
|
keys?: string[];
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
|
onLoaded?: (settings: SettingsStateProps) => void;
|
||||||
}>) {
|
}>) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsState.loaded) {
|
||||||
|
// Call the onLoaded callback if provided
|
||||||
|
onLoaded?.(settingsState);
|
||||||
|
}
|
||||||
|
}, [settingsState.loaded, settingsState.settings]);
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
const allKeys = useMemo(
|
const allKeys = useMemo(
|
||||||
@@ -189,14 +205,39 @@ export function GlobalSettingList({ keys }: Readonly<{ keys: string[] }>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PluginSettingList({
|
export function PluginSettingList({
|
||||||
pluginKey
|
pluginKey,
|
||||||
}: Readonly<{ pluginKey: string }>) {
|
onLoaded
|
||||||
|
}: Readonly<{
|
||||||
|
pluginKey: string;
|
||||||
|
onLoaded?: (settings: SettingsStateProps) => void;
|
||||||
|
}>) {
|
||||||
const pluginSettingsStore = useRef(
|
const pluginSettingsStore = useRef(
|
||||||
createPluginSettingsState({ plugin: pluginKey })
|
createPluginSettingsState({
|
||||||
|
plugin: pluginKey,
|
||||||
|
endpoint: ApiEndpoints.plugin_setting_list
|
||||||
|
})
|
||||||
).current;
|
).current;
|
||||||
const pluginSettings = useStore(pluginSettingsStore);
|
const pluginSettings = useStore(pluginSettingsStore);
|
||||||
|
|
||||||
return <SettingList settingsState={pluginSettings} />;
|
return <SettingList settingsState={pluginSettings} onLoaded={onLoaded} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginUserSettingList({
|
||||||
|
pluginKey,
|
||||||
|
onLoaded
|
||||||
|
}: Readonly<{
|
||||||
|
pluginKey: string;
|
||||||
|
onLoaded?: (settings: SettingsStateProps) => void;
|
||||||
|
}>) {
|
||||||
|
const pluginUserSettingsState = useRef(
|
||||||
|
createPluginSettingsState({
|
||||||
|
plugin: pluginKey,
|
||||||
|
endpoint: ApiEndpoints.plugin_user_setting_list
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
const pluginUserSettings = useStore(pluginUserSettingsState);
|
||||||
|
|
||||||
|
return <SettingList settingsState={pluginUserSettings} onLoaded={onLoaded} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MachineSettingList({
|
export function MachineSettingList({
|
||||||
|
|||||||
117
src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx
Normal file
117
src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { ApiEndpoints } from '@lib/index';
|
||||||
|
import type { SettingsStateProps } from '@lib/types/Settings';
|
||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Accordion, Alert, Group, Stack, Text } from '@mantine/core';
|
||||||
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
PluginSettingList,
|
||||||
|
PluginUserSettingList
|
||||||
|
} from '../../../components/settings/SettingList';
|
||||||
|
import { useInstance } from '../../../hooks/UseInstance';
|
||||||
|
|
||||||
|
function PluginSettingGroupItem({
|
||||||
|
global,
|
||||||
|
pluginKey,
|
||||||
|
pluginName,
|
||||||
|
pluginDescription
|
||||||
|
}: {
|
||||||
|
global: boolean;
|
||||||
|
pluginKey: string;
|
||||||
|
pluginName: string;
|
||||||
|
pluginDescription?: string;
|
||||||
|
}) {
|
||||||
|
// Hide the accordion item if there are no settings for this plugin
|
||||||
|
const [count, setCount] = useState<number>(0);
|
||||||
|
|
||||||
|
// Callback once the plugin settings have been loaded
|
||||||
|
const onLoaded = useCallback(
|
||||||
|
(settings: SettingsStateProps) => {
|
||||||
|
setCount(settings.settings?.length || 0);
|
||||||
|
},
|
||||||
|
[pluginKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Item
|
||||||
|
key={`plugin-${pluginKey}`}
|
||||||
|
value={pluginKey}
|
||||||
|
hidden={count === 0}
|
||||||
|
>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group>
|
||||||
|
<Text size='lg'>{pluginName}</Text>
|
||||||
|
{pluginDescription && <Text size='sm'>{pluginDescription}</Text>}
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
{global ? (
|
||||||
|
<PluginSettingList pluginKey={pluginKey} onLoaded={onLoaded} />
|
||||||
|
) : (
|
||||||
|
<PluginUserSettingList pluginKey={pluginKey} onLoaded={onLoaded} />
|
||||||
|
)}
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an accordion of user-specific plugin settings
|
||||||
|
* - Each element in the accordion corresponds to a plugin
|
||||||
|
* - Each plugin can have multiple settings
|
||||||
|
* - If a plugin has no settings, it will not be displayed
|
||||||
|
*/
|
||||||
|
export default function PluginSettingsGroup({
|
||||||
|
mixin,
|
||||||
|
message,
|
||||||
|
global
|
||||||
|
}: {
|
||||||
|
global: boolean;
|
||||||
|
message?: string;
|
||||||
|
mixin?: string;
|
||||||
|
}) {
|
||||||
|
const mixins: string = useMemo(() => {
|
||||||
|
const mixinList: string[] = ['settings'];
|
||||||
|
|
||||||
|
if (mixin) {
|
||||||
|
mixinList.push(mixin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mixinList.join(',');
|
||||||
|
}, [mixin]);
|
||||||
|
|
||||||
|
// All *active* plugins which require settings
|
||||||
|
const activePlugins = useInstance({
|
||||||
|
endpoint: ApiEndpoints.plugin_list,
|
||||||
|
params: {
|
||||||
|
active: true,
|
||||||
|
mixin: mixins
|
||||||
|
},
|
||||||
|
hasPrimaryKey: false,
|
||||||
|
defaultValue: []
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<Alert color='blue' icon={<IconInfoCircle />}>
|
||||||
|
<Text>
|
||||||
|
{message ??
|
||||||
|
t`The settings below are specific to each available plugin`}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
<Accordion multiple>
|
||||||
|
{activePlugins.instance?.map((plugin: any) => {
|
||||||
|
return (
|
||||||
|
<PluginSettingGroupItem
|
||||||
|
global={global}
|
||||||
|
key={plugin.key}
|
||||||
|
pluginKey={plugin.key}
|
||||||
|
pluginName={plugin.meta?.human_name ?? plugin.name}
|
||||||
|
pluginDescription={plugin?.meta?.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
import { Skeleton, Stack } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBellCog,
|
IconBellCog,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconFileAnalytics,
|
IconFileAnalytics,
|
||||||
IconFingerprint,
|
IconFingerprint,
|
||||||
IconInfoCircle,
|
|
||||||
IconPackages,
|
IconPackages,
|
||||||
|
IconPlugConnected,
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
IconServerCog,
|
IconServerCog,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
IconTruckDelivery,
|
IconTruckDelivery,
|
||||||
IconTruckReturn
|
IconTruckReturn
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { lazy, useMemo } from 'react';
|
||||||
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
||||||
@@ -25,9 +25,14 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
|||||||
import type { PanelType } from '../../../components/panels/Panel';
|
import type { PanelType } from '../../../components/panels/Panel';
|
||||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||||
|
import { Loadable } from '../../../functions/loading';
|
||||||
import { useServerApiState } from '../../../states/ServerApiState';
|
import { useServerApiState } from '../../../states/ServerApiState';
|
||||||
import { useUserState } from '../../../states/UserState';
|
import { useUserState } from '../../../states/UserState';
|
||||||
|
|
||||||
|
const PluginSettingsGroup = Loadable(
|
||||||
|
lazy(() => import('./PluginSettingsGroup'))
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System settings page
|
* System settings page
|
||||||
*/
|
*/
|
||||||
@@ -113,15 +118,11 @@ export default function SystemSettings() {
|
|||||||
label: t`Notifications`,
|
label: t`Notifications`,
|
||||||
icon: <IconBellCog />,
|
icon: <IconBellCog />,
|
||||||
content: (
|
content: (
|
||||||
<Stack>
|
<PluginSettingsGroup
|
||||||
<Alert
|
mixin='notification'
|
||||||
color='teal'
|
global={true}
|
||||||
title={t`This panel is a placeholder.`}
|
message={t`The settings below are specific to each available notification method`}
|
||||||
icon={<IconInfoCircle />}
|
/>
|
||||||
>
|
|
||||||
<Text c='gray'>This panel has not yet been implemented</Text>
|
|
||||||
</Alert>
|
|
||||||
</Stack>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -305,6 +306,12 @@ export default function SystemSettings() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plugins',
|
||||||
|
label: t`Plugin Settings`,
|
||||||
|
icon: <IconPlugConnected />,
|
||||||
|
content: <PluginSettingsGroup global={true} />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
IconDeviceDesktop,
|
IconDeviceDesktop,
|
||||||
IconFileAnalytics,
|
IconFileAnalytics,
|
||||||
IconLock,
|
IconLock,
|
||||||
|
IconPlugConnected,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconUserCircle
|
IconUserCircle
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { lazy, useMemo } from 'react';
|
||||||
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import PageTitle from '../../../components/nav/PageTitle';
|
import PageTitle from '../../../components/nav/PageTitle';
|
||||||
@@ -16,10 +17,15 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
|||||||
import type { PanelType } from '../../../components/panels/Panel';
|
import type { PanelType } from '../../../components/panels/Panel';
|
||||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||||
|
import { Loadable } from '../../../functions/loading';
|
||||||
import { useUserState } from '../../../states/UserState';
|
import { useUserState } from '../../../states/UserState';
|
||||||
import { SecurityContent } from './AccountSettings/SecurityContent';
|
import { SecurityContent } from './AccountSettings/SecurityContent';
|
||||||
import { AccountContent } from './AccountSettings/UserPanel';
|
import { AccountContent } from './AccountSettings/UserPanel';
|
||||||
|
|
||||||
|
const PluginSettingsGroup = Loadable(
|
||||||
|
lazy(() => import('./PluginSettingsGroup'))
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User settings page
|
* User settings page
|
||||||
*/
|
*/
|
||||||
@@ -97,7 +103,13 @@ export default function UserSettings() {
|
|||||||
name: 'notifications',
|
name: 'notifications',
|
||||||
label: t`Notifications`,
|
label: t`Notifications`,
|
||||||
icon: <IconBellCog />,
|
icon: <IconBellCog />,
|
||||||
content: <UserSettingList keys={['NOTIFICATION_ERROR_REPORT']} />
|
content: (
|
||||||
|
<PluginSettingsGroup
|
||||||
|
mixin='notification'
|
||||||
|
global={false}
|
||||||
|
message={t`The settings below are specific to each available notification method`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reporting',
|
name: 'reporting',
|
||||||
@@ -108,6 +120,12 @@ export default function UserSettings() {
|
|||||||
keys={['REPORT_INLINE', 'LABEL_INLINE', 'LABEL_DEFAULT_PRINTER']}
|
keys={['REPORT_INLINE', 'LABEL_INLINE', 'LABEL_DEFAULT_PRINTER']}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plugins',
|
||||||
|
label: t`Plugin Settings`,
|
||||||
|
icon: <IconPlugConnected />,
|
||||||
|
content: <PluginSettingsGroup global={false} />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -123,10 +123,12 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
|
|||||||
*/
|
*/
|
||||||
interface CreatePluginSettingStateProps {
|
interface CreatePluginSettingStateProps {
|
||||||
plugin: string;
|
plugin: string;
|
||||||
|
endpoint: ApiEndpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPluginSettingsState = ({
|
export const createPluginSettingsState = ({
|
||||||
plugin
|
plugin,
|
||||||
|
endpoint
|
||||||
}: CreatePluginSettingStateProps) => {
|
}: CreatePluginSettingStateProps) => {
|
||||||
const pathParams: PathParams = { plugin };
|
const pathParams: PathParams = { plugin };
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ export const createPluginSettingsState = ({
|
|||||||
lookup: {},
|
lookup: {},
|
||||||
loaded: false,
|
loaded: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
endpoint: ApiEndpoints.plugin_setting_list,
|
endpoint: endpoint,
|
||||||
pathParams,
|
pathParams,
|
||||||
fetchSettings: async () => {
|
fetchSettings: async () => {
|
||||||
let success = true;
|
let success = true;
|
||||||
@@ -155,7 +157,7 @@ export const createPluginSettingsState = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await api
|
await api
|
||||||
.get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin }))
|
.get(apiUrl(endpoint, undefined, { plugin }))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const settings = response.data;
|
const settings = response.data;
|
||||||
set({
|
set({
|
||||||
@@ -166,7 +168,9 @@ export const createPluginSettingsState = ({
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((_error) => {
|
.catch((_error) => {
|
||||||
console.error(`Error fetching plugin settings for plugin ${plugin}`);
|
console.error(
|
||||||
|
`ERR: Could not fetch plugin settings for plugin ${plugin}`
|
||||||
|
);
|
||||||
success = false;
|
success = false;
|
||||||
set({
|
set({
|
||||||
loaded: false,
|
loaded: false,
|
||||||
@@ -186,7 +190,6 @@ export const createPluginSettingsState = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('fetching plugin settings for', plugin);
|
|
||||||
store.getState().fetchSettings();
|
store.getState().fetchSettings();
|
||||||
}, [plugin]);
|
}, [plugin]);
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,47 @@ test('Plugins - Settings', async ({ browser, request }) => {
|
|||||||
await page.getByText('Mouser Electronics').click();
|
await page.getByText('Mouser Electronics').click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Plugins - User Settings', async ({ browser, request }) => {
|
||||||
|
const page = await doCachedLogin(browser);
|
||||||
|
|
||||||
|
// Ensure that the SampleIntegration plugin is enabled
|
||||||
|
await setPluginState({
|
||||||
|
request,
|
||||||
|
plugin: 'sample',
|
||||||
|
state: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to user settings
|
||||||
|
await navigate(page, 'settings/user/');
|
||||||
|
await loadTab(page, 'Plugin Settings');
|
||||||
|
|
||||||
|
// User settings for the "Sample Plugin" should be visible
|
||||||
|
await page.getByRole('button', { name: 'Sample Plugin' }).click();
|
||||||
|
|
||||||
|
await page.getByText('User Setting 1').waitFor();
|
||||||
|
await page.getByText('User Setting 2').waitFor();
|
||||||
|
await page.getByText('User Setting 3').waitFor();
|
||||||
|
|
||||||
|
// Check for expected setting options
|
||||||
|
await page.getByLabel('edit-setting-USER_SETTING_3').click();
|
||||||
|
|
||||||
|
const val = await page.getByLabel('choice-field-value').inputValue();
|
||||||
|
|
||||||
|
await page.getByLabel('choice-field-value').click();
|
||||||
|
|
||||||
|
await page.getByRole('option', { name: 'Choice X' }).waitFor();
|
||||||
|
await page.getByRole('option', { name: 'Choice Y' }).waitFor();
|
||||||
|
await page.getByRole('option', { name: 'Choice Z' }).waitFor();
|
||||||
|
|
||||||
|
// Change the value of USER_SETTING_3
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: val == 'Choice X' ? 'Choice Z' : 'Choice X' })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
await page.getByText('Setting USER_SETTING_3 updated successfully').waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
// Test base plugin functionality
|
// Test base plugin functionality
|
||||||
test('Plugins - Functionality', async ({ browser }) => {
|
test('Plugins - Functionality', async ({ browser }) => {
|
||||||
// Navigate and select the plugin
|
// Navigate and select the plugin
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test } from './baseFixtures.js';
|
|||||||
import { apiUrl } from './defaults.js';
|
import { apiUrl } from './defaults.js';
|
||||||
import { getRowFromCell, loadTab, navigate } from './helpers.js';
|
import { getRowFromCell, loadTab, navigate } from './helpers.js';
|
||||||
import { doCachedLogin } from './login.js';
|
import { doCachedLogin } from './login.js';
|
||||||
import { setSettingState } from './settings.js';
|
import { setPluginState, setSettingState } from './settings.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adjust language and color settings
|
* Adjust language and color settings
|
||||||
@@ -80,6 +80,97 @@ test('Settings - User theme', async ({ browser }) => {
|
|||||||
await page.getByLabel('#228be6').click();
|
await page.getByLabel('#228be6').click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Settings - User', async ({ browser }) => {
|
||||||
|
const page = await doCachedLogin(browser, {
|
||||||
|
username: 'allaccess',
|
||||||
|
password: 'nolimits',
|
||||||
|
url: 'settings/user/'
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTab(page, 'Account');
|
||||||
|
await page.getByText('Account Details').waitFor();
|
||||||
|
await page.getByText('Profile Details').waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Security');
|
||||||
|
await page.getByRole('button', { name: 'Single Sign On' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Access Tokens' }).waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Display Options');
|
||||||
|
await page
|
||||||
|
.getByText('The navbar position is fixed to the top of the screen')
|
||||||
|
.waitFor();
|
||||||
|
await page.getByText('Escape Key Closes Forms').waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Search');
|
||||||
|
await page.getByText('Whole Word Search').waitFor();
|
||||||
|
await page.getByText('Hide Unavailable Stock Items').waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Notifications');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'InvenTree Email Notifications' })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Reporting');
|
||||||
|
await page.getByText('Inline report display').waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Plugin Settings');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'InvenTree Email Notifications' })
|
||||||
|
.waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings - Global', async ({ browser, request }) => {
|
||||||
|
const page = await doCachedLogin(browser, {
|
||||||
|
username: 'steven',
|
||||||
|
password: 'wizardstaff',
|
||||||
|
url: 'settings/system/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the "slack" notification plugin is enabled
|
||||||
|
// This is to ensure it is visible in the "notification" settings tab
|
||||||
|
await setPluginState({
|
||||||
|
request,
|
||||||
|
plugin: 'inventree-slack-notification',
|
||||||
|
state: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTab(page, 'Server');
|
||||||
|
await loadTab(page, 'Authentication');
|
||||||
|
await loadTab(page, 'Barcodes');
|
||||||
|
await loadTab(page, 'Pricing');
|
||||||
|
await loadTab(page, 'Parts');
|
||||||
|
await loadTab(page, 'Stock');
|
||||||
|
|
||||||
|
await loadTab(page, 'Notifications');
|
||||||
|
await page
|
||||||
|
.getByText(
|
||||||
|
'The settings below are specific to each available notification method'
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'InvenTree Slack Notifications' })
|
||||||
|
.click();
|
||||||
|
await page.getByText('Slack incoming webhook url').waitFor();
|
||||||
|
await page
|
||||||
|
.getByText('URL that is used to send messages to a slack channel')
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
await loadTab(page, 'Plugin Settings');
|
||||||
|
await page
|
||||||
|
.getByText('The settings below are specific to each available plugin')
|
||||||
|
.waitFor();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'InvenTree Barcodes Provides' })
|
||||||
|
.waitFor();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'InvenTree PDF label printer' })
|
||||||
|
.waitFor();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'InvenTree Slack Notifications' })
|
||||||
|
.waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
test('Settings - Admin', async ({ browser }) => {
|
test('Settings - Admin', async ({ browser }) => {
|
||||||
// Note here we login with admin access
|
// Note here we login with admin access
|
||||||
const page = await doCachedLogin(browser, {
|
const page = await doCachedLogin(browser, {
|
||||||
|
|||||||
Reference in New Issue
Block a user