mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-30 00:21:34 +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 |
|
||||
| [Currency Exchange](./currency_exchange.md) | Currency exchange rate plugin | Yes |
|
||||
| [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 Core Notifications](./notifications.md) | Core notification system | 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 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 |
|
||||
| [Mouser](./barcode_mouser.md) | Mouser barcode support | No |
|
||||
| [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 |
|
||||
| [UI Notification](./ui_notification.md) | UI notification plugin | Yes |
|
||||
|
||||
### 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 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.
|
||||
|
||||
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:
|
||||
|
||||
|
@@ -227,6 +227,7 @@ nav:
|
||||
- Label Printing Mixin: plugins/mixins/label.md
|
||||
- Locate Mixin: plugins/mixins/locate.md
|
||||
- Navigation Mixin: plugins/mixins/navigation.md
|
||||
- Notification Mixin: plugins/mixins/notification.md
|
||||
- Report Mixin: plugins/mixins/report.md
|
||||
- Schedule Mixin: plugins/mixins/schedule.md
|
||||
- Settings Mixin: plugins/mixins/settings.md
|
||||
@@ -238,24 +239,30 @@ nav:
|
||||
- Label Printer: plugins/machines/label_printer.md
|
||||
- Builtin Plugins:
|
||||
- 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: plugins/builtin/barcode_index.md
|
||||
- InvenTree Barcode: plugins/builtin/inventree_barcode.md
|
||||
- DigiKey Barcode Plugin: plugins/builtin/barcode_digikey.md
|
||||
- LCSC Barcode Plugin: plugins/builtin/barcode_lcsc.md
|
||||
- Mouser Barcode Plugin: plugins/builtin/barcode_mouser.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
|
||||
|
||||
# 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."""
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
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
|
||||
- Adds "can_build" field to the part requirements API endpoint
|
||||
|
@@ -36,7 +36,6 @@ class InvenTreeConfig(AppConfig):
|
||||
- Cleaning up tasks
|
||||
- Starting regular tasks
|
||||
- Updating exchange rates
|
||||
- Collecting notification methods
|
||||
- Collecting state transition methods
|
||||
- Adding users set in the current environment
|
||||
"""
|
||||
@@ -71,7 +70,6 @@ class InvenTreeConfig(AppConfig):
|
||||
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
|
||||
|
||||
self.update_site_url()
|
||||
self.collect_notification_methods()
|
||||
self.collect_state_transition_methods()
|
||||
|
||||
# Ensure the unit registry is loaded
|
||||
@@ -314,12 +312,6 @@ class InvenTreeConfig(AppConfig):
|
||||
# do not try again
|
||||
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):
|
||||
"""Collect all state transition methods."""
|
||||
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 not from_email:
|
||||
if settings.TESTING:
|
||||
from_email = 'from@test.com'
|
||||
from_email = 'test@test.inventree.org'
|
||||
else:
|
||||
logger.error(
|
||||
'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."""
|
||||
import common.models
|
||||
import common.notifications
|
||||
from plugin.builtin.integration.core_notifications import InvenTreeUINotifications
|
||||
|
||||
try:
|
||||
# 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 = []
|
||||
|
||||
@@ -1219,7 +1220,7 @@ def notify_staff_users_of_error(instance, label: str, context: dict):
|
||||
label,
|
||||
context=context,
|
||||
targets=target_users,
|
||||
delivery_methods={common.notifications.UIMessageNotification},
|
||||
delivery_methods={InvenTreeUINotifications},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
|
@@ -365,6 +365,14 @@ class UserSettingsPermissionsOrScope(OASTokenMixin, permissions.BasePermission):
|
||||
|
||||
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):
|
||||
"""Return the required scopes for the current request."""
|
||||
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.utils import NotSupportedError, OperationalError, ProgrammingError
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
import structlog
|
||||
@@ -470,7 +471,10 @@ def delete_old_notifications():
|
||||
def check_for_updates():
|
||||
"""Check if there is an update for InvenTree."""
|
||||
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
|
||||
# Apps not yet loaded!
|
||||
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
|
||||
if not isInvenTreeUpToDate():
|
||||
logger.warning('InvenTree is not up-to-date, sending notification')
|
||||
|
||||
plg = registry.get_plugin('InvenTreeCoreNotificationsPlugin')
|
||||
if not plg:
|
||||
logger.warning('Cannot send notification - plugin not found')
|
||||
return
|
||||
plg = plg.plugin_config()
|
||||
if not plg:
|
||||
logger.warning('Cannot send notification - plugin config not found')
|
||||
return
|
||||
# Send notification
|
||||
trigger_superuser_notification(
|
||||
plg, f'An update for InvenTree to version {tag} is available'
|
||||
# Send notification to superusers
|
||||
trigger_notification(
|
||||
None,
|
||||
'update_available',
|
||||
targets=get_user_model().objects.filter(is_superuser=True),
|
||||
delivery_methods={InvenTreeUINotifications},
|
||||
context={
|
||||
'name': _('Update Available'),
|
||||
'message': _('An update for InvenTree 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
|
||||
"""
|
||||
from plugin import registry
|
||||
|
||||
def set_pending_migrations(n: int):
|
||||
"""Helper function to inform the user about pending migrations."""
|
||||
|
@@ -152,11 +152,6 @@ def setting_object(key, *args, **kwargs):
|
||||
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:
|
||||
return common.models.InvenTreeUserSetting.get_setting_object(
|
||||
key, user=kwargs['user'], cache=cache
|
||||
|
@@ -592,13 +592,15 @@ class GeneralApiTests(InvenTreeAPITestCase):
|
||||
self.assertEqual('InvenTree', data['server'])
|
||||
|
||||
# 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()
|
||||
|
||||
# Anon
|
||||
response = self.get(url)
|
||||
response = self.get(url, max_query_count=275)
|
||||
self.assertEqual(response.json()['database'], None)
|
||||
|
||||
# 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)
|
||||
|
@@ -14,7 +14,7 @@ from django_q.models import Schedule, Task
|
||||
from error_report.models import Error
|
||||
|
||||
import InvenTree.tasks
|
||||
from common.models import InvenTreeSetting
|
||||
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||
|
||||
threshold = timezone.now() - timedelta(days=30)
|
||||
threshold_low = threshold - timedelta(days=1)
|
||||
@@ -191,7 +191,7 @@ class InvenTreeTaskTests(TestCase):
|
||||
|
||||
# Create a staff user (to ensure notifications are sent)
|
||||
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()
|
||||
@@ -220,6 +220,9 @@ class InvenTreeTaskTests(TestCase):
|
||||
user.is_staff = True
|
||||
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
|
||||
# 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)
|
||||
|
@@ -677,12 +677,15 @@ class AdminTestCase(InvenTreeAPITestCase):
|
||||
app_app, app_mdl = model._meta.app_label, model._meta.model_name
|
||||
|
||||
# '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)
|
||||
|
||||
# Test change view
|
||||
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.assertContains(response, 'Django site admin')
|
||||
|
@@ -13,6 +13,8 @@ class BuildEvents(BaseEventEnum):
|
||||
COMPLETED = 'build.completed'
|
||||
OVERDUE = 'build.overdue_build_order'
|
||||
|
||||
STOCK_REQUIRED = 'build.stock_required'
|
||||
|
||||
# Build output events
|
||||
OUTPUT_CREATED = 'buildoutput.created'
|
||||
OUTPUT_COMPLETED = 'buildoutput.completed'
|
||||
|
@@ -760,6 +760,11 @@ class Build(
|
||||
|
||||
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
|
||||
def hold_build(self):
|
||||
"""Mark the Build as ON HOLD."""
|
||||
@@ -1504,8 +1509,6 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
|
||||
):
|
||||
return
|
||||
|
||||
from . import tasks as build_tasks
|
||||
|
||||
if instance:
|
||||
if 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
|
||||
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
|
||||
InvenTree.helpers_model.notify_responsible(
|
||||
instance,
|
||||
|
@@ -4,16 +4,13 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import structlog
|
||||
from allauth.account.models import EmailAddress
|
||||
from opentelemetry import trace
|
||||
|
||||
import common.notifications
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_email
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.tasks
|
||||
from build.events import BuildEvents
|
||||
@@ -175,34 +172,30 @@ def check_build_stock(build):
|
||||
return
|
||||
|
||||
# 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:
|
||||
logger.info('Notifying users of stock required for build %s', build.pk)
|
||||
name = _('Stock required for build order')
|
||||
|
||||
context = {
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
build.get_absolute_url()
|
||||
),
|
||||
'build': build,
|
||||
'part': build.part,
|
||||
'lines': lines,
|
||||
}
|
||||
context = {
|
||||
'build': build,
|
||||
'name': name,
|
||||
'part': build.part,
|
||||
'lines': lines,
|
||||
'link': InvenTree.helpers_model.construct_absolute_url(
|
||||
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
|
||||
html_message = render_to_string(
|
||||
'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
|
||||
)
|
||||
common.notifications.trigger_notification(
|
||||
build, BuildEvents.STOCK_REQUIRED, targets=targets, context=context
|
||||
)
|
||||
|
||||
|
||||
@tracer.start_as_current_span('notify_overdue_build_order')
|
||||
|
@@ -55,8 +55,6 @@ from InvenTree.permissions import (
|
||||
IsSuperuserOrSuperScope,
|
||||
UserSettingsPermissionsOrScope,
|
||||
)
|
||||
from plugin.models import NotificationUserSetting
|
||||
from plugin.serializers import NotificationUserSettingSerializer
|
||||
|
||||
|
||||
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:
|
||||
"""Generic mixin for NotificationMessage."""
|
||||
|
||||
@@ -962,24 +930,6 @@ settings_api_urls = [
|
||||
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
|
||||
path(
|
||||
'global/',
|
||||
|
@@ -603,7 +603,15 @@ class BaseInvenTreeSetting(models.Model):
|
||||
if not setting and create:
|
||||
# Attempt to create a new settings object
|
||||
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:
|
||||
# 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 common.models
|
||||
import InvenTree.helpers
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.ready import isImportingData, isRebuildingData
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting, PluginConfig
|
||||
from plugin import PluginMixinEnum, registry
|
||||
from users.models import Owner
|
||||
from users.permissions import check_user_permission
|
||||
|
||||
@@ -108,194 +107,6 @@ class NotificationMethod:
|
||||
|
||||
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()
|
||||
class NotificationBody:
|
||||
@@ -389,16 +200,16 @@ def trigger_notification(
|
||||
obj_ref_value = None
|
||||
|
||||
# Find the first reference that is available
|
||||
for ref in refs:
|
||||
if hasattr(obj, ref):
|
||||
obj_ref_value = getattr(obj, ref)
|
||||
break
|
||||
if obj:
|
||||
for ref in refs:
|
||||
if hasattr(obj, ref):
|
||||
obj_ref_value = getattr(obj, ref)
|
||||
break
|
||||
|
||||
# Try with some defaults
|
||||
if not obj_ref_value:
|
||||
raise KeyError(
|
||||
f"Could not resolve an object reference for '{obj!s}' with {','.join(set(refs))}"
|
||||
)
|
||||
if not obj_ref_value:
|
||||
raise KeyError(
|
||||
f"Could not resolve an object reference for '{obj!s}' with {','.join(set(refs))}"
|
||||
)
|
||||
|
||||
# Check if we have notified recently...
|
||||
delta = timedelta(days=1)
|
||||
@@ -419,7 +230,7 @@ def trigger_notification(
|
||||
target_exclude = set()
|
||||
|
||||
# Collect possible targets
|
||||
if not targets:
|
||||
if not targets and target_fnc:
|
||||
targets = target_fnc(*target_args, **target_kwargs)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
if target_users:
|
||||
# Filter out any users who are inactive, or do not have the required model permissions
|
||||
valid_users = list(
|
||||
filter(
|
||||
lambda u: u and u.is_active and check_user_permission(u, obj, 'view'),
|
||||
list(target_users),
|
||||
)
|
||||
# Filter out any users who are inactive, or do not have the required model permissions
|
||||
valid_users = list(
|
||||
filter(
|
||||
lambda u: u
|
||||
and u.is_active
|
||||
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(
|
||||
cls: NotificationMethod, obj: Model, category: str, targets: list, context: dict
|
||||
):
|
||||
"""Send notification with the provided class.
|
||||
# Send out via all registered notification methods
|
||||
for plugin in registry.with_mixin(PluginMixinEnum.NOTIFICATION):
|
||||
# Skip if the plugin is *not* in the "delivery_methods" list?
|
||||
match = not delivery_methods
|
||||
|
||||
Arguments:
|
||||
cls: The class that should be used to send the notification
|
||||
obj: The object (model instance) that triggered the notification
|
||||
category: The category (label) for the notification
|
||||
targets: List of users that should receive the notification
|
||||
context: Context dictionary with additional information for the notification
|
||||
for notification_class in delivery_methods or []:
|
||||
if type(notification_class) is str:
|
||||
if plugin.slug == notification_class:
|
||||
match = True
|
||||
break
|
||||
|
||||
- Initializes the method
|
||||
- Checks that there are valid targets
|
||||
- Runs the delivery setup
|
||||
- Sends notifications either via `send_bulk` or send`
|
||||
- Runs the delivery cleanup
|
||||
"""
|
||||
# Init delivery method
|
||||
method = cls(obj, category, targets, context)
|
||||
elif getattr(notification_class, 'SLUG', None) == plugin.slug:
|
||||
match = True
|
||||
break
|
||||
|
||||
if method.targets and len(method.targets) > 0:
|
||||
# Log start
|
||||
logger.info(
|
||||
"Notify users via '%s' for notification '%s' for '%s'",
|
||||
method.METHOD_NAME,
|
||||
category,
|
||||
str(obj),
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
# Run setup for delivery method
|
||||
method.setup()
|
||||
try:
|
||||
# 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
|
||||
success = True
|
||||
success_count = 0
|
||||
|
||||
# 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')
|
||||
# Log the notification entry
|
||||
if result:
|
||||
common.models.NotificationEntry.notify(category, obj_ref_value)
|
||||
|
@@ -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 plugin import registry
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
from .api import WebhookView
|
||||
from .models import (
|
||||
@@ -797,28 +796,6 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
|
||||
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):
|
||||
"""Tests for the plugin settings API."""
|
||||
|
||||
|
@@ -26,7 +26,7 @@ def _clean_storage(refs):
|
||||
|
||||
|
||||
class TransitionTests(InvenTreeTestCase):
|
||||
"""Tests for basic NotificationMethod."""
|
||||
"""Tests for basic TransitionMethod."""
|
||||
|
||||
def test_class(self):
|
||||
"""Ensure that the class itself works."""
|
||||
|
@@ -197,7 +197,9 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
}
|
||||
|
||||
# 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})
|
||||
pk = response.data['pk']
|
||||
|
||||
@@ -231,13 +233,16 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
|
||||
def test_machine_detail_settings(self):
|
||||
"""Test machine detail settings API endpoint."""
|
||||
# TODO: Investigate why these tests need a higher query limit
|
||||
QUERY_LIMIT = 300
|
||||
|
||||
machine_setting_url = reverse(
|
||||
'api-machine-settings-detail',
|
||||
kwargs={'pk': self.placeholder_uuid, 'config_type': 'M', 'key': 'LOCATION'},
|
||||
)
|
||||
|
||||
# Test machine settings for non-existent machine
|
||||
self.get(machine_setting_url, expected_code=404)
|
||||
self.get(machine_setting_url, expected_code=404, max_query_count=QUERY_LIMIT)
|
||||
|
||||
# Create a machine
|
||||
machine = MachineConfig.objects.create(
|
||||
@@ -257,18 +262,22 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
# Get settings
|
||||
response = self.get(machine_setting_url)
|
||||
response = self.get(machine_setting_url, max_query_count=QUERY_LIMIT)
|
||||
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'], '')
|
||||
|
||||
# Update machine setting
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
# Update driver setting
|
||||
@@ -280,7 +289,7 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
|
||||
# Get list of all settings for a machine
|
||||
settings_url = reverse('api-machine-settings', kwargs={'pk': machine.pk})
|
||||
response = self.get(settings_url)
|
||||
response = self.get(settings_url, max_query_count=QUERY_LIMIT)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(
|
||||
[('M', 'LOCATION'), ('D', 'TEST_SETTING')],
|
||||
|
@@ -157,14 +157,13 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase):
|
||||
self.assertEqual(registry.get_drivers('testing-type')[0].SLUG, 'test-driver')
|
||||
|
||||
# test that init hooks where called correctly
|
||||
self.driver_mocks['init_driver'].assert_called_once()
|
||||
self.assertEqual(self.driver_mocks['init_machine'].call_count, 2)
|
||||
CALL_COUNT = range(1, 5) # Due to interplay between plugin and machine registry
|
||||
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
|
||||
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)
|
||||
|
||||
# Test machine update hook
|
||||
|
@@ -7,11 +7,8 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
import part.settings
|
||||
from common.models import NotificationEntry, NotificationMessage
|
||||
from common.notifications import UIMessageNotification, storage
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from InvenTree import version
|
||||
from InvenTree.templatetags import inventree_extras
|
||||
@@ -924,67 +921,39 @@ class PartSubscriptionTests(InvenTreeTestCase):
|
||||
self.assertTrue(self.part.is_starred_by(self.user))
|
||||
|
||||
|
||||
class BaseNotificationIntegrationTest(InvenTreeTestCase):
|
||||
"""Integration test for notifications."""
|
||||
class PartNotificationTest(InvenTreeTestCase):
|
||||
"""Integration test for part notifications."""
|
||||
|
||||
fixtures = ['location', 'category', 'part', 'stock']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""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)
|
||||
|
||||
def test_low_stock_notification(self):
|
||||
"""Test that a low stocknotification is generated."""
|
||||
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(NotificationMessage.objects.all().count(), 0)
|
||||
|
||||
# Test that notifications run through without errors
|
||||
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
|
||||
# Subscribe the user to the part
|
||||
addUserPermission(self.user, 'part', 'part', 'view')
|
||||
self.user.is_active = True
|
||||
self.user.save()
|
||||
self.part.set_starred(self.user, True)
|
||||
self.part.save()
|
||||
part.set_starred(self.user, True)
|
||||
part.save()
|
||||
|
||||
# There should be 1 (or 2) notifications - in some cases an error is generated, which creates a subsequent notification
|
||||
self.assertIn(NotificationEntry.objects.all().count(), [1, 2])
|
||||
# Check that a UI notification entry has been created
|
||||
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):
|
||||
"""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)
|
||||
self.assertEqual(Error.objects.count(), 0)
|
||||
|
@@ -46,6 +46,18 @@ class PluginSettingInline(admin.TabularInline):
|
||||
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):
|
||||
"""Custom admin with restricted id fields."""
|
||||
|
||||
@@ -61,21 +73,9 @@ class PluginConfigAdmin(admin.ModelAdmin):
|
||||
]
|
||||
list_filter = ['active']
|
||||
actions = [plugin_activate, plugin_deactivate]
|
||||
inlines = [PluginSettingInline]
|
||||
inlines = [PluginSettingInline, PluginUserSettingInline]
|
||||
exclude = ['metadata']
|
||||
|
||||
|
||||
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
|
||||
search_fields = ['name', 'key']
|
||||
|
||||
|
||||
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.locate.api import LocatePluginView
|
||||
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.registry import registry
|
||||
|
||||
@@ -332,19 +332,19 @@ def check_plugin(
|
||||
|
||||
# Check that the 'plugin' specified is valid
|
||||
try:
|
||||
plugin_cgf = PluginConfig.objects.filter(**filters).first()
|
||||
plugin_cfg = PluginConfig.objects.filter(**filters).first()
|
||||
except PluginConfig.DoesNotExist:
|
||||
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
|
||||
raise NotFound(detail=f"Plugin '{ref}' not installed") # pragma: no cover
|
||||
|
||||
# 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")
|
||||
|
||||
plugin = plugin_cgf.plugin
|
||||
plugin = plugin_cfg.plugin
|
||||
|
||||
if not plugin:
|
||||
raise NotFound(detail=f"Plugin '{ref}' not installed")
|
||||
@@ -381,10 +381,7 @@ class PluginAllSettingList(APIView):
|
||||
|
||||
|
||||
class PluginSettingDetail(RetrieveUpdateAPI):
|
||||
"""Detail endpoint for a plugin-specific setting.
|
||||
|
||||
Note that these cannot be created or deleted via the API
|
||||
"""
|
||||
"""Detail endpoint for a plugin-specific setting."""
|
||||
|
||||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
@@ -415,6 +412,65 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
||||
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):
|
||||
"""Status API endpoint for the plugin registry.
|
||||
|
||||
@@ -484,6 +540,21 @@ plugin_api_urls = [
|
||||
path(
|
||||
'<str:plugin>/',
|
||||
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(
|
||||
'settings/',
|
||||
include([
|
||||
|
@@ -22,6 +22,10 @@ class PluginAppConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""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():
|
||||
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."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
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
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from common.models import SettingsKeyType
|
||||
else:
|
||||
|
||||
class User:
|
||||
"""Dummy class, so that python throws no error."""
|
||||
|
||||
class SettingsKeyType:
|
||||
"""Dummy class, so that python throws no error."""
|
||||
|
||||
@@ -23,6 +28,7 @@ class SettingsMixin:
|
||||
"""Mixin that enables global settings for the plugin."""
|
||||
|
||||
SETTINGS: dict[str, SettingsKeyType] = {}
|
||||
USER_SETTINGS: dict[str, SettingsKeyType] = {}
|
||||
|
||||
class MixinMeta:
|
||||
"""Meta for mixin."""
|
||||
@@ -34,6 +40,7 @@ class SettingsMixin:
|
||||
super().__init__()
|
||||
self.add_mixin(PluginMixinEnum.SETTINGS, 'has_settings', __class__)
|
||||
self.settings = getattr(self, 'SETTINGS', {})
|
||||
self.user_settings = getattr(self, 'USER_SETTINGS', {})
|
||||
|
||||
@classmethod
|
||||
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
|
||||
@@ -45,12 +52,16 @@ class SettingsMixin:
|
||||
logger.debug('Activating plugin settings')
|
||||
|
||||
registry.mixins_settings = {}
|
||||
registry.mixins_user_settings = {}
|
||||
|
||||
for slug, plugin in plugins:
|
||||
if plugin.mixin_enabled(PluginMixinEnum.SETTINGS):
|
||||
plugin_setting = plugin.settings
|
||||
plugin_setting = plugin.settings or {}
|
||||
registry.mixins_settings[slug] = plugin_setting
|
||||
|
||||
plugin_user_setting = plugin.user_settings or {}
|
||||
registry.mixins_user_settings[slug] = plugin_user_setting
|
||||
|
||||
@classmethod
|
||||
def _deactivate_mixin(cls, registry, **kwargs):
|
||||
"""Deactivate all plugin settings."""
|
||||
@@ -61,14 +72,16 @@ class SettingsMixin:
|
||||
@property
|
||||
def has_settings(self):
|
||||
"""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.
|
||||
|
||||
Arguments:
|
||||
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
|
||||
"""
|
||||
from plugin.models import PluginSetting
|
||||
@@ -77,8 +90,16 @@ class SettingsMixin:
|
||||
key, plugin=self.plugin_config(), cache=cache, backup_value=backup_value
|
||||
)
|
||||
|
||||
def set_setting(self, key, value, user=None):
|
||||
"""Set plugin setting value by key."""
|
||||
def set_setting(
|
||||
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.registry import registry
|
||||
|
||||
@@ -92,7 +113,52 @@ class SettingsMixin:
|
||||
logger.error("Plugin configuration not found for plugin '%s'", self.slug)
|
||||
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):
|
||||
"""Check if all required settings for this machine are defined.
|
||||
|
@@ -24,6 +24,8 @@ def print_label(plugin_slug: str, **kwargs):
|
||||
kwargs:
|
||||
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)
|
||||
|
||||
plugin = registry.get_plugin(plugin_slug, active=True)
|
||||
@@ -53,7 +55,7 @@ def print_label(plugin_slug: str, **kwargs):
|
||||
'label.printing_failed',
|
||||
targets=[user],
|
||||
context=ctx,
|
||||
delivery_methods={common.notifications.UIMessageNotification},
|
||||
delivery_methods={InvenTreeUINotifications},
|
||||
)
|
||||
|
||||
if settings.TESTING:
|
||||
|
@@ -115,11 +115,10 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
||||
|
||||
# Plugin is not a label plugin
|
||||
registry.set_plugin_state('digikeyplugin', True)
|
||||
no_valid_plg = registry.get_plugin('digikeyplugin').plugin_config()
|
||||
|
||||
response = self.post(
|
||||
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,
|
||||
)
|
||||
|
||||
|
@@ -1,175 +1,169 @@
|
||||
"""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.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from allauth.account.models import EmailAddress
|
||||
import structlog
|
||||
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_email
|
||||
import InvenTree.tasks
|
||||
from plugin import InvenTreePlugin, registry
|
||||
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
||||
from common.settings import get_global_setting
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import NotificationMixin, SettingsMixin
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
|
||||
class PlgMixin:
|
||||
"""Mixin to access plugin easier.
|
||||
class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
|
||||
"""Plugin mixin class for supporting UI notification methods."""
|
||||
|
||||
This needs to be spit out to reference the class. Perks of python.
|
||||
"""
|
||||
|
||||
def get_plugin(self):
|
||||
"""Return plugin reference."""
|
||||
return InvenTreeCoreNotificationsPlugin
|
||||
|
||||
|
||||
class InvenTreeCoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
|
||||
"""Core notification methods for InvenTree."""
|
||||
|
||||
NAME = 'InvenTreeCoreNotificationsPlugin'
|
||||
TITLE = _('InvenTree Notifications')
|
||||
NAME = 'InvenTreeUINotifications'
|
||||
TITLE = _('InvenTree UI Notifications')
|
||||
SLUG = 'inventree-ui-notification'
|
||||
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'
|
||||
|
||||
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': {
|
||||
'name': _('Slack incoming webhook url'),
|
||||
'description': _('URL that is used to send messages to a slack channel'),
|
||||
'protected': True,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
def get_settings_content(self, request):
|
||||
"""Custom settings content for the plugin."""
|
||||
return """
|
||||
<p>Setup for Slack:</p>
|
||||
<ol>
|
||||
<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>
|
||||
"""
|
||||
def send_notification(
|
||||
self, target: Model, category: str, users: list[User], context: dict
|
||||
) -> bool:
|
||||
"""Send the notifications out via slack."""
|
||||
url = self.get_setting('NOTIFICATION_SLACK_URL')
|
||||
|
||||
class EmailNotification(PlgMixin, BulkNotificationMethod):
|
||||
"""Notification method for delivery via Email."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
METHOD_NAME = 'mail'
|
||||
METHOD_ICON = 'fa-envelope'
|
||||
CONTEXT_EXTRA = [('template',), ('template', 'html'), ('template', 'subject')]
|
||||
GLOBAL_SETTING = 'ENABLE_NOTIFICATION_EMAILS'
|
||||
USER_SETTING = {
|
||||
'name': _('Enable email notifications'),
|
||||
'description': _('Allow sending of emails for event notifications'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
}
|
||||
|
||||
def get_targets(self):
|
||||
"""Return a list of target email addresses, only for users which allow email notifications."""
|
||||
allowed_users = []
|
||||
|
||||
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',
|
||||
ret = requests.post(
|
||||
url,
|
||||
json={
|
||||
'text': str(context['message']),
|
||||
'blocks': [
|
||||
{
|
||||
'type': 'section',
|
||||
'text': {'type': 'plain_text', 'text': str(context['name'])},
|
||||
},
|
||||
{
|
||||
'type': 'section',
|
||||
'text': {'type': 'mrkdwn', 'text': str(context['message'])},
|
||||
'accessory': {
|
||||
'type': 'button',
|
||||
'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']),
|
||||
},
|
||||
'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
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
return ret.ok
|
||||
|
@@ -2,35 +2,64 @@
|
||||
|
||||
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.builtin.integration.core_notifications import (
|
||||
InvenTreeCoreNotificationsPlugin,
|
||||
)
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
|
||||
class CoreNotificationTestTests(BaseNotificationIntegrationTest):
|
||||
class CoreNotificationTestTests(InvenTreeTestCase):
|
||||
"""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):
|
||||
"""Ensure that the email notifications run."""
|
||||
# No email should be send
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
# enable plugin and set mail setting to true
|
||||
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,
|
||||
)
|
||||
print('- get email plugin:')
|
||||
|
||||
# run through
|
||||
self._notification_run(InvenTreeCoreNotificationsPlugin.EmailNotification)
|
||||
plugin = registry.get_plugin('inventree-email-notification')
|
||||
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)
|
||||
|
@@ -113,6 +113,15 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
|
||||
|
||||
def test_old_mouser_barcode(self):
|
||||
"""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(
|
||||
self.SCAN_URL, data={'barcode': MOUSER_BARCODE_OLD}, expected_code=200
|
||||
)
|
||||
@@ -171,6 +180,11 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
|
||||
"""Create supplier part and purchase_order."""
|
||||
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_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."""
|
||||
|
||||
from common.notifications import BulkNotificationMethod, SingleNotificationMethod
|
||||
from plugin.base.action.mixins import ActionMixin
|
||||
from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin
|
||||
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.DataExport import DataExportMixin
|
||||
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.ScheduleMixin import ScheduleMixin
|
||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||
@@ -25,7 +25,6 @@ __all__ = [
|
||||
'ActionMixin',
|
||||
'AppMixin',
|
||||
'BarcodeMixin',
|
||||
'BulkNotificationMethod',
|
||||
'CurrencyExchangeMixin',
|
||||
'DataExportMixin',
|
||||
'EventMixin',
|
||||
@@ -34,10 +33,10 @@ __all__ = [
|
||||
'LocateMixin',
|
||||
'MailMixin',
|
||||
'NavigationMixin',
|
||||
'NotificationMixin',
|
||||
'ReportMixin',
|
||||
'ScheduleMixin',
|
||||
'SettingsMixin',
|
||||
'SingleNotificationMethod',
|
||||
'SupplierBarcodeMixin',
|
||||
'UrlsMixin',
|
||||
'UserInterfaceMixin',
|
||||
|
@@ -292,37 +292,60 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
return super().get_setting_definition(key, **kwargs)
|
||||
|
||||
|
||||
class NotificationUserSetting(common.models.BaseInvenTreeSetting):
|
||||
"""This model represents notification settings for a user."""
|
||||
class PluginUserSetting(common.models.BaseInvenTreeSetting):
|
||||
"""This model represents user-specific settings for individual plugins.
|
||||
|
||||
typ = 'notification'
|
||||
extra_unique_fields = ['method', 'user']
|
||||
In contrast with the PluginSetting model, which holds global settings for plugins,
|
||||
this model allows for user-specific settings that can be defined by each user.
|
||||
"""
|
||||
|
||||
typ = 'plugin_user'
|
||||
extra_unique_fields = ['plugin', 'user']
|
||||
|
||||
class Meta:
|
||||
"""Meta for NotificationUserSetting."""
|
||||
"""Meta for PluginUserSetting."""
|
||||
|
||||
unique_together = [('method', 'user', 'key')]
|
||||
unique_together = [('plugin', 'user', 'key')]
|
||||
|
||||
@classmethod
|
||||
def get_setting_definition(cls, key, **kwargs):
|
||||
"""Override setting_definition to use notification settings."""
|
||||
from common.notifications import storage
|
||||
|
||||
kwargs['settings'] = storage.user_settings
|
||||
|
||||
return super().get_setting_definition(key, **kwargs)
|
||||
|
||||
method = models.CharField(max_length=255, verbose_name=_('Method'))
|
||||
plugin = models.ForeignKey(
|
||||
PluginConfig,
|
||||
related_name='user_settings',
|
||||
null=False,
|
||||
verbose_name=_('Plugin'),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
null=False,
|
||||
verbose_name=_('User'),
|
||||
help_text=_('User'),
|
||||
related_name='plugin_settings',
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Nice name of printing."""
|
||||
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'
|
||||
MAIL = 'mail'
|
||||
NAVIGATION = 'navigation'
|
||||
NOTIFICATION = 'notification'
|
||||
REPORT = 'report'
|
||||
SCHEDULE = 'schedule'
|
||||
SETTINGS = 'settings'
|
||||
|
@@ -92,9 +92,9 @@ class PluginsRegistry:
|
||||
'inventreebarcode',
|
||||
'bom-exporter',
|
||||
'inventree-exporter',
|
||||
'inventreecorenotificationsplugin',
|
||||
'inventree-ui-notification',
|
||||
'inventree-email-notification',
|
||||
'inventreecurrencyexchange',
|
||||
'inventreecorenotificationsplugin',
|
||||
'inventreelabel',
|
||||
'inventreelabelmachine',
|
||||
'parameter-exporter',
|
||||
|
@@ -47,6 +47,26 @@ class SampleIntegrationPlugin(
|
||||
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 = {
|
||||
'PO_FUNCTION_ENABLE': {
|
||||
'name': _('Enable PO'),
|
||||
|
@@ -8,7 +8,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.serializers import GenericReferencedSettingSerializer
|
||||
from plugin.models import NotificationUserSetting, PluginConfig, PluginSetting
|
||||
from plugin.models import PluginConfig, PluginSetting, PluginUserSetting
|
||||
|
||||
|
||||
class MetadataSerializer(serializers.ModelSerializer):
|
||||
@@ -275,14 +275,16 @@ class PluginSettingSerializer(GenericReferencedSettingSerializer):
|
||||
plugin = serializers.CharField(source='plugin.key', read_only=True)
|
||||
|
||||
|
||||
class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
|
||||
"""Serializer for the PluginSetting model."""
|
||||
class PluginUserSettingSerializer(GenericReferencedSettingSerializer):
|
||||
"""Serializer for the PluginUserSetting model."""
|
||||
|
||||
MODEL = NotificationUserSetting
|
||||
EXTRA_FIELDS = ['method']
|
||||
MODEL = PluginUserSetting
|
||||
EXTRA_FIELDS = ['plugin', 'user']
|
||||
|
||||
method = serializers.CharField(read_only=True)
|
||||
typ = serializers.CharField(read_only=True)
|
||||
plugin = serializers.CharField(source='plugin.key', read_only=True)
|
||||
user = serializers.PrimaryKeyRelatedField(
|
||||
read_only=True, help_text=_('The user for which this setting applies')
|
||||
)
|
||||
|
||||
|
||||
class PluginRegistryErrorSerializer(serializers.Serializer):
|
||||
|
@@ -315,6 +315,64 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
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):
|
||||
"""Test metadata endpoint for plugin."""
|
||||
self.user.is_superuser = True
|
||||
|
@@ -11,7 +11,7 @@
|
||||
{% endblock title %}
|
||||
|
||||
{% 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">
|
||||
<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 %}
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
{% decimal line.required %}
|
||||
{% decimal line.required %} {% if part.units %} [{{ part.units }}]{% endif %}
|
||||
</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>
|
||||
|
||||
{% 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
|
||||
'plugin_pluginconfig',
|
||||
'plugin_pluginsetting',
|
||||
'plugin_notificationusersetting',
|
||||
'plugin_pluginusersetting',
|
||||
# Misc
|
||||
'common_barcodescanresult',
|
||||
'common_newsfeedentry',
|
||||
|
@@ -208,6 +208,7 @@ export enum ApiEndpoints {
|
||||
// Plugin API endpoints
|
||||
plugin_list = 'plugins/',
|
||||
plugin_setting_list = 'plugins/:plugin/settings/',
|
||||
plugin_user_setting_list = 'plugins/:plugin/user-settings/',
|
||||
plugin_registry_status = 'plugins/status/',
|
||||
plugin_install = 'plugins/install/',
|
||||
plugin_reload = 'plugins/reload/',
|
||||
|
@@ -2,9 +2,16 @@ import { t } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
||||
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 { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import type { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { Setting, SettingsStateProps } from '@lib/types/Settings';
|
||||
@@ -25,12 +32,21 @@ import { SettingItem } from './SettingItem';
|
||||
export function SettingList({
|
||||
settingsState,
|
||||
keys,
|
||||
onChange
|
||||
onChange,
|
||||
onLoaded
|
||||
}: Readonly<{
|
||||
settingsState: SettingsStateProps;
|
||||
keys?: string[];
|
||||
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 allKeys = useMemo(
|
||||
@@ -189,14 +205,39 @@ export function GlobalSettingList({ keys }: Readonly<{ keys: string[] }>) {
|
||||
}
|
||||
|
||||
export function PluginSettingList({
|
||||
pluginKey
|
||||
}: Readonly<{ pluginKey: string }>) {
|
||||
pluginKey,
|
||||
onLoaded
|
||||
}: Readonly<{
|
||||
pluginKey: string;
|
||||
onLoaded?: (settings: SettingsStateProps) => void;
|
||||
}>) {
|
||||
const pluginSettingsStore = useRef(
|
||||
createPluginSettingsState({ plugin: pluginKey })
|
||||
createPluginSettingsState({
|
||||
plugin: pluginKey,
|
||||
endpoint: ApiEndpoints.plugin_setting_list
|
||||
})
|
||||
).current;
|
||||
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({
|
||||
|
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 { Alert, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconBellCog,
|
||||
IconCategory,
|
||||
IconCurrencyDollar,
|
||||
IconFileAnalytics,
|
||||
IconFingerprint,
|
||||
IconInfoCircle,
|
||||
IconPackages,
|
||||
IconPlugConnected,
|
||||
IconQrcode,
|
||||
IconServerCog,
|
||||
IconShoppingCart,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import PermissionDenied from '../../../components/errors/PermissionDenied';
|
||||
@@ -25,9 +25,14 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import type { PanelType } from '../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../functions/loading';
|
||||
import { useServerApiState } from '../../../states/ServerApiState';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
|
||||
const PluginSettingsGroup = Loadable(
|
||||
lazy(() => import('./PluginSettingsGroup'))
|
||||
);
|
||||
|
||||
/**
|
||||
* System settings page
|
||||
*/
|
||||
@@ -113,15 +118,11 @@ export default function SystemSettings() {
|
||||
label: t`Notifications`,
|
||||
icon: <IconBellCog />,
|
||||
content: (
|
||||
<Stack>
|
||||
<Alert
|
||||
color='teal'
|
||||
title={t`This panel is a placeholder.`}
|
||||
icon={<IconInfoCircle />}
|
||||
>
|
||||
<Text c='gray'>This panel has not yet been implemented</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
<PluginSettingsGroup
|
||||
mixin='notification'
|
||||
global={true}
|
||||
message={t`The settings below are specific to each available notification method`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -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,
|
||||
IconFileAnalytics,
|
||||
IconLock,
|
||||
IconPlugConnected,
|
||||
IconSearch,
|
||||
IconUserCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import PageTitle from '../../../components/nav/PageTitle';
|
||||
@@ -16,10 +17,15 @@ import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||
import type { PanelType } from '../../../components/panels/Panel';
|
||||
import { PanelGroup } from '../../../components/panels/PanelGroup';
|
||||
import { UserSettingList } from '../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../functions/loading';
|
||||
import { useUserState } from '../../../states/UserState';
|
||||
import { SecurityContent } from './AccountSettings/SecurityContent';
|
||||
import { AccountContent } from './AccountSettings/UserPanel';
|
||||
|
||||
const PluginSettingsGroup = Loadable(
|
||||
lazy(() => import('./PluginSettingsGroup'))
|
||||
);
|
||||
|
||||
/**
|
||||
* User settings page
|
||||
*/
|
||||
@@ -97,7 +103,13 @@ export default function UserSettings() {
|
||||
name: 'notifications',
|
||||
label: t`Notifications`,
|
||||
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',
|
||||
@@ -108,6 +120,12 @@ export default function UserSettings() {
|
||||
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 {
|
||||
plugin: string;
|
||||
endpoint: ApiEndpoints;
|
||||
}
|
||||
|
||||
export const createPluginSettingsState = ({
|
||||
plugin
|
||||
plugin,
|
||||
endpoint
|
||||
}: CreatePluginSettingStateProps) => {
|
||||
const pathParams: PathParams = { plugin };
|
||||
|
||||
@@ -135,7 +137,7 @@ export const createPluginSettingsState = ({
|
||||
lookup: {},
|
||||
loaded: false,
|
||||
isError: false,
|
||||
endpoint: ApiEndpoints.plugin_setting_list,
|
||||
endpoint: endpoint,
|
||||
pathParams,
|
||||
fetchSettings: async () => {
|
||||
let success = true;
|
||||
@@ -155,7 +157,7 @@ export const createPluginSettingsState = ({
|
||||
});
|
||||
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin }))
|
||||
.get(apiUrl(endpoint, undefined, { plugin }))
|
||||
.then((response) => {
|
||||
const settings = response.data;
|
||||
set({
|
||||
@@ -166,7 +168,9 @@ export const createPluginSettingsState = ({
|
||||
});
|
||||
})
|
||||
.catch((_error) => {
|
||||
console.error(`Error fetching plugin settings for plugin ${plugin}`);
|
||||
console.error(
|
||||
`ERR: Could not fetch plugin settings for plugin ${plugin}`
|
||||
);
|
||||
success = false;
|
||||
set({
|
||||
loaded: false,
|
||||
@@ -186,7 +190,6 @@ export const createPluginSettingsState = ({
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
console.log('fetching plugin settings for', plugin);
|
||||
store.getState().fetchSettings();
|
||||
}, [plugin]);
|
||||
|
||||
|
@@ -65,6 +65,47 @@ test('Plugins - Settings', async ({ browser, request }) => {
|
||||
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('Plugins - Functionality', async ({ browser }) => {
|
||||
// Navigate and select the plugin
|
||||
|
@@ -2,7 +2,7 @@ import { expect, test } from './baseFixtures.js';
|
||||
import { apiUrl } from './defaults.js';
|
||||
import { getRowFromCell, loadTab, navigate } from './helpers.js';
|
||||
import { doCachedLogin } from './login.js';
|
||||
import { setSettingState } from './settings.js';
|
||||
import { setPluginState, setSettingState } from './settings.js';
|
||||
|
||||
/**
|
||||
* Adjust language and color settings
|
||||
@@ -80,6 +80,97 @@ test('Settings - User theme', async ({ browser }) => {
|
||||
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 }) => {
|
||||
// Note here we login with admin access
|
||||
const page = await doCachedLogin(browser, {
|
||||
|
Reference in New Issue
Block a user