From 1085625af409c6b4b17e2d264b8f1fe2559c12d0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 26 Jul 2025 13:05:59 +1000 Subject: [PATCH] [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 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../plugins/builtin/email_notification.md | 9 + docs/docs/plugins/builtin/index.md | 6 +- docs/docs/plugins/builtin/notifications.md | 21 -- .../plugins/builtin/slack_notification.md | 11 + docs/docs/plugins/builtin/ui_notification.md | 15 + docs/docs/plugins/mixins/notification.md | 45 +++ docs/docs/plugins/mixins/settings.md | 60 ++- docs/mkdocs.yml | 31 +- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/apps.py | 8 - .../InvenTree/InvenTree/helpers_email.py | 2 +- src/backend/InvenTree/InvenTree/models.py | 5 +- .../InvenTree/InvenTree/permissions.py | 8 + src/backend/InvenTree/InvenTree/tasks.py | 30 +- .../templatetags/inventree_extras.py | 5 - src/backend/InvenTree/InvenTree/test_api.py | 8 +- src/backend/InvenTree/InvenTree/test_tasks.py | 7 +- src/backend/InvenTree/InvenTree/unit_test.py | 7 +- src/backend/InvenTree/build/events.py | 2 + src/backend/InvenTree/build/models.py | 12 +- src/backend/InvenTree/build/tasks.py | 47 +-- src/backend/InvenTree/common/api.py | 50 --- src/backend/InvenTree/common/models.py | 10 +- src/backend/InvenTree/common/notifications.py | 356 +++--------------- .../InvenTree/common/test_notifications.py | 149 -------- src/backend/InvenTree/common/tests.py | 23 -- .../generic/states/test_transition.py | 2 +- src/backend/InvenTree/machine/test_api.py | 23 +- src/backend/InvenTree/machine/tests.py | 9 +- src/backend/InvenTree/part/test_part.py | 77 ++-- src/backend/InvenTree/plugin/admin.py | 28 +- src/backend/InvenTree/plugin/api.py | 89 ++++- src/backend/InvenTree/plugin/apps.py | 4 + .../base/integration/NotificationMixin.py | 55 +++ .../plugin/base/integration/SettingsMixin.py | 82 +++- .../InvenTree/plugin/base/label/label.py | 4 +- .../plugin/base/label/test_label_mixin.py | 3 +- .../builtin/integration/core_notifications.py | 290 +++++++------- .../integration/test_core_notifications.py | 67 +++- .../suppliers/test_supplier_barcodes.py | 14 + .../migrations/0010_pluginusersetting.py | 59 +++ .../0011_delete_notificationusersetting.py | 16 + .../InvenTree/plugin/mixins/__init__.py | 5 +- src/backend/InvenTree/plugin/models.py | 59 ++- src/backend/InvenTree/plugin/plugin.py | 1 + src/backend/InvenTree/plugin/registry.py | 4 +- .../plugin/samples/integration/sample.py | 20 + src/backend/InvenTree/plugin/serializers.py | 16 +- src/backend/InvenTree/plugin/test_api.py | 58 +++ .../email/build_order_required_stock.html | 6 +- .../InvenTree/templates/email/test_email.html | 5 + src/backend/InvenTree/users/ruleset.py | 2 +- src/frontend/lib/enums/ApiEndpoints.tsx | 1 + .../src/components/settings/SettingList.tsx | 53 ++- .../Index/Settings/PluginSettingsGroup.tsx | 117 ++++++ .../pages/Index/Settings/SystemSettings.tsx | 31 +- .../src/pages/Index/Settings/UserSettings.tsx | 22 +- src/frontend/src/states/SettingsStates.tsx | 13 +- src/frontend/tests/pui_plugins.spec.ts | 41 ++ src/frontend/tests/pui_settings.spec.ts | 93 ++++- 60 files changed, 1329 insertions(+), 972 deletions(-) create mode 100644 docs/docs/plugins/builtin/email_notification.md delete mode 100644 docs/docs/plugins/builtin/notifications.md create mode 100644 docs/docs/plugins/builtin/slack_notification.md create mode 100644 docs/docs/plugins/builtin/ui_notification.md create mode 100644 docs/docs/plugins/mixins/notification.md delete mode 100644 src/backend/InvenTree/common/test_notifications.py create mode 100644 src/backend/InvenTree/plugin/base/integration/NotificationMixin.py create mode 100644 src/backend/InvenTree/plugin/migrations/0010_pluginusersetting.py create mode 100644 src/backend/InvenTree/plugin/migrations/0011_delete_notificationusersetting.py create mode 100644 src/backend/InvenTree/templates/email/test_email.html create mode 100644 src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx diff --git a/docs/docs/plugins/builtin/email_notification.md b/docs/docs/plugins/builtin/email_notification.md new file mode 100644 index 0000000000..f08a0f4688 --- /dev/null +++ b/docs/docs/plugins/builtin/email_notification.md @@ -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. diff --git a/docs/docs/plugins/builtin/index.md b/docs/docs/plugins/builtin/index.md index 80a11fbe9b..113c31afd7 100644 --- a/docs/docs/plugins/builtin/index.md +++ b/docs/docs/plugins/builtin/index.md @@ -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 diff --git a/docs/docs/plugins/builtin/notifications.md b/docs/docs/plugins/builtin/notifications.md deleted file mode 100644 index 4e3a5a34e2..0000000000 --- a/docs/docs/plugins/builtin/notifications.md +++ /dev/null @@ -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. diff --git a/docs/docs/plugins/builtin/slack_notification.md b/docs/docs/plugins/builtin/slack_notification.md new file mode 100644 index 0000000000..e32e95d126 --- /dev/null +++ b/docs/docs/plugins/builtin/slack_notification.md @@ -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. diff --git a/docs/docs/plugins/builtin/ui_notification.md b/docs/docs/plugins/builtin/ui_notification.md new file mode 100644 index 0000000000..a9a992ca43 --- /dev/null +++ b/docs/docs/plugins/builtin/ui_notification.md @@ -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. diff --git a/docs/docs/plugins/mixins/notification.md b/docs/docs/plugins/mixins/notification.md new file mode 100644 index 0000000000..df493f31bc --- /dev/null +++ b/docs/docs/plugins/mixins/notification.md @@ -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) diff --git a/docs/docs/plugins/mixins/settings.md b/docs/docs/plugins/mixins/settings.md index 27fd3f7fe8..d1ee19378b 100644 --- a/docs/docs/plugins/mixins/settings.md +++ b/docs/docs/plugins/mixins/settings.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: diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a21050db10..5c52d899f5 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 67d5757464..4705d1c590 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index 4abe5a6b30..857aff1d13 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/helpers_email.py b/src/backend/InvenTree/InvenTree/helpers_email.py index 57518ee5e9..3d9f21c539 100644 --- a/src/backend/InvenTree/InvenTree/helpers_email.py +++ b/src/backend/InvenTree/InvenTree/helpers_email.py @@ -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' diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 2600bad2d8..2f1341563a 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -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: diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index 9f5edea30a..3fc25b744e 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -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) diff --git a/src/backend/InvenTree/InvenTree/tasks.py b/src/backend/InvenTree/InvenTree/tasks.py index 18dbe44110..ac10a91c4c 100644 --- a/src/backend/InvenTree/InvenTree/tasks.py +++ b/src/backend/InvenTree/InvenTree/tasks.py @@ -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.""" diff --git a/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py b/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py index 10c3c74783..8d12601121 100644 --- a/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py +++ b/src/backend/InvenTree/InvenTree/templatetags/inventree_extras.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 3754109e8b..72aa28fe1f 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -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) diff --git a/src/backend/InvenTree/InvenTree/test_tasks.py b/src/backend/InvenTree/InvenTree/test_tasks.py index fa68634946..b94b266c32 100644 --- a/src/backend/InvenTree/InvenTree/test_tasks.py +++ b/src/backend/InvenTree/InvenTree/test_tasks.py @@ -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) diff --git a/src/backend/InvenTree/InvenTree/unit_test.py b/src/backend/InvenTree/InvenTree/unit_test.py index 2836bbf198..589aeb5745 100644 --- a/src/backend/InvenTree/InvenTree/unit_test.py +++ b/src/backend/InvenTree/InvenTree/unit_test.py @@ -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') diff --git a/src/backend/InvenTree/build/events.py b/src/backend/InvenTree/build/events.py index 2e60df6c28..df14dc2baa 100644 --- a/src/backend/InvenTree/build/events.py +++ b/src/backend/InvenTree/build/events.py @@ -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' diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 3b176a8317..e06ab5f890 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -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, diff --git a/src/backend/InvenTree/build/tasks.py b/src/backend/InvenTree/build/tasks.py index 9320cfd5dc..29fded5bc9 100644 --- a/src/backend/InvenTree/build/tasks.py +++ b/src/backend/InvenTree/build/tasks.py @@ -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') diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 866996a77f..14bae578aa 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -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( - '/', - NotificationUserSettingsDetail.as_view(), - name='api-notification-setting-detail', - ), - # Notification Settings List - path( - '', - NotificationUserSettingsList.as_view(), - name='api-notification-setting-list', - ), - ]), - ), # Global settings path( 'global/', diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 1ece0f99a1..df01282760 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -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 diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index 26f1935de8..3b1309c78f 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -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) diff --git a/src/backend/InvenTree/common/test_notifications.py b/src/backend/InvenTree/common/test_notifications.py deleted file mode 100644 index b190034289..0000000000 --- a/src/backend/InvenTree/common/test_notifications.py +++ /dev/null @@ -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) diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index b9c7ea390d..3d4ac83d72 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -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.""" diff --git a/src/backend/InvenTree/generic/states/test_transition.py b/src/backend/InvenTree/generic/states/test_transition.py index 38f4946e32..9e3e53958b 100644 --- a/src/backend/InvenTree/generic/states/test_transition.py +++ b/src/backend/InvenTree/generic/states/test_transition.py @@ -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.""" diff --git a/src/backend/InvenTree/machine/test_api.py b/src/backend/InvenTree/machine/test_api.py index 74dda3641a..c64d0ed774 100644 --- a/src/backend/InvenTree/machine/test_api.py +++ b/src/backend/InvenTree/machine/test_api.py @@ -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')], diff --git a/src/backend/InvenTree/machine/tests.py b/src/backend/InvenTree/machine/tests.py index 94b7491df5..e103644962 100755 --- a/src/backend/InvenTree/machine/tests.py +++ b/src/backend/InvenTree/machine/tests.py @@ -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 diff --git a/src/backend/InvenTree/part/test_part.py b/src/backend/InvenTree/part/test_part.py index a6f2f5bddb..61703664d6 100644 --- a/src/backend/InvenTree/part/test_part.py +++ b/src/backend/InvenTree/part/test_part.py @@ -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) diff --git a/src/backend/InvenTree/plugin/admin.py b/src/backend/InvenTree/plugin/admin.py index 49afa1aed6..a1643bc405 100644 --- a/src/backend/InvenTree/plugin/admin.py +++ b/src/backend/InvenTree/plugin/admin.py @@ -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) diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index dacd6e2ac8..2491eafc9f 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -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( '/', include([ + path( + 'user-settings/', + include([ + re_path( + r'^(?P\w+)/', + PluginUserSettingDetail.as_view(), + name='api-plugin-user-setting-detail', + ), + path( + '', + PluginUserSettingList.as_view(), + name='api-plugin-user-setting-list', + ), + ]), + ), path( 'settings/', include([ diff --git a/src/backend/InvenTree/plugin/apps.py b/src/backend/InvenTree/plugin/apps.py index cd2474bb9f..f37e91d006 100644 --- a/src/backend/InvenTree/plugin/apps.py +++ b/src/backend/InvenTree/plugin/apps.py @@ -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 diff --git a/src/backend/InvenTree/plugin/base/integration/NotificationMixin.py b/src/backend/InvenTree/plugin/base/integration/NotificationMixin.py new file mode 100644 index 0000000000..79bdf1eeed --- /dev/null +++ b/src/backend/InvenTree/plugin/base/integration/NotificationMixin.py @@ -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 diff --git a/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py b/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py index 215795718a..34216b0261 100644 --- a/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/SettingsMixin.py @@ -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. diff --git a/src/backend/InvenTree/plugin/base/label/label.py b/src/backend/InvenTree/plugin/base/label/label.py index ea6e229bdc..d99badd928 100644 --- a/src/backend/InvenTree/plugin/base/label/label.py +++ b/src/backend/InvenTree/plugin/base/label/label.py @@ -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: diff --git a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py index b58cbcb608..d8baf529c7 100644 --- a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py +++ b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py @@ -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, ) diff --git a/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py b/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py index b0c7d86475..76a9e98e79 100644 --- a/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py @@ -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 """ -

Setup for Slack:

-
    -
  1. Create a new Slack app on this page
  2. -
  3. Enable Incoming Webhooks for the channel you want the notifications posted to
  4. -
  5. Set the webhook URL in the settings above
  6. -
  7. Enable the plugin
  8. - """ + 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 diff --git a/src/backend/InvenTree/plugin/builtin/integration/test_core_notifications.py b/src/backend/InvenTree/plugin/builtin/integration/test_core_notifications.py index 62d1c2b1ed..fbff6bc894 100644 --- a/src/backend/InvenTree/plugin/builtin/integration/test_core_notifications.py +++ b/src/backend/InvenTree/plugin/builtin/integration/test_core_notifications.py @@ -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) diff --git a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index a5698f9a3a..5ae4a6dce7 100644 --- a/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/src/backend/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -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') diff --git a/src/backend/InvenTree/plugin/migrations/0010_pluginusersetting.py b/src/backend/InvenTree/plugin/migrations/0010_pluginusersetting.py new file mode 100644 index 0000000000..53ca727a5e --- /dev/null +++ b/src/backend/InvenTree/plugin/migrations/0010_pluginusersetting.py @@ -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")}, + }, + ), + ] diff --git a/src/backend/InvenTree/plugin/migrations/0011_delete_notificationusersetting.py b/src/backend/InvenTree/plugin/migrations/0011_delete_notificationusersetting.py new file mode 100644 index 0000000000..3f85d00e64 --- /dev/null +++ b/src/backend/InvenTree/plugin/migrations/0011_delete_notificationusersetting.py @@ -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", + ), + ] diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index 442e201d22..4ac0c05892 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -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', diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index c5947c24f5..bbe5559338 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -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) diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index 5474f6e17b..660d4d2fde 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -38,6 +38,7 @@ class PluginMixinEnum(StringEnum): LOCATE = 'locate' MAIL = 'mail' NAVIGATION = 'navigation' + NOTIFICATION = 'notification' REPORT = 'report' SCHEDULE = 'schedule' SETTINGS = 'settings' diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index ac8d816532..85726f15bc 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -92,9 +92,9 @@ class PluginsRegistry: 'inventreebarcode', 'bom-exporter', 'inventree-exporter', - 'inventreecorenotificationsplugin', + 'inventree-ui-notification', + 'inventree-email-notification', 'inventreecurrencyexchange', - 'inventreecorenotificationsplugin', 'inventreelabel', 'inventreelabelmachine', 'parameter-exporter', diff --git a/src/backend/InvenTree/plugin/samples/integration/sample.py b/src/backend/InvenTree/plugin/samples/integration/sample.py index 95177fd7a4..3fe7c134ce 100644 --- a/src/backend/InvenTree/plugin/samples/integration/sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/sample.py @@ -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'), diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index ca35eb3eb9..518e8687a4 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -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): diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index b3a0a78e81..6d61340482 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -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 diff --git a/src/backend/InvenTree/templates/email/build_order_required_stock.html b/src/backend/InvenTree/templates/email/build_order_required_stock.html index fa1100afd6..51bb78d8c9 100644 --- a/src/backend/InvenTree/templates/email/build_order_required_stock.html +++ b/src/backend/InvenTree/templates/email/build_order_required_stock.html @@ -11,7 +11,7 @@ {% endblock title %} {% block body %} -{% trans "The following parts are low on required stock" %} +{% trans "The following parts are low on required stock" %} {% trans "Part" %} @@ -25,9 +25,9 @@ {{ line.part.full_name }}{% if line.part.description %} - {{ line.part.description }}{% endif %} - {% decimal line.required %} + {% decimal line.required %} {% if part.units %} [{{ part.units }}]{% endif %} - {% decimal line.available %} + {% decimal line.available %} {% if part.units %} [{{ part.units }}]{% endif %} {% endfor %} diff --git a/src/backend/InvenTree/templates/email/test_email.html b/src/backend/InvenTree/templates/email/test_email.html new file mode 100644 index 0000000000..63411e0d5c --- /dev/null +++ b/src/backend/InvenTree/templates/email/test_email.html @@ -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. diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index 95df91cdff..aaf0cea096 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -79,7 +79,7 @@ def get_ruleset_models() -> dict: # Plugins 'plugin_pluginconfig', 'plugin_pluginsetting', - 'plugin_notificationusersetting', + 'plugin_pluginusersetting', # Misc 'common_barcodescanresult', 'common_newsfeedentry', diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 49c7e93fbb..f8d9f4967a 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -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/', diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 22676cde46..f54960e86c 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -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 ; + return ; +} + +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 ; } export function MachineSettingList({ diff --git a/src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx b/src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx new file mode 100644 index 0000000000..5f0c406f3c --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/PluginSettingsGroup.tsx @@ -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(0); + + // Callback once the plugin settings have been loaded + const onLoaded = useCallback( + (settings: SettingsStateProps) => { + setCount(settings.settings?.length || 0); + }, + [pluginKey] + ); + + return ( + + ); +} + +/** + * 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 ( + + }> + + {message ?? + t`The settings below are specific to each available plugin`} + + + + {activePlugins.instance?.map((plugin: any) => { + return ( + + ); + })} + + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 9dba178e2b..d467a059a6 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -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: , content: ( - - } - > - This panel has not yet been implemented - - + ) }, { @@ -305,6 +306,12 @@ export default function SystemSettings() { ]} /> ) + }, + { + name: 'plugins', + label: t`Plugin Settings`, + icon: , + content: } ]; }, []); diff --git a/src/frontend/src/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx index c2297e651b..cd03d70aa2 100644 --- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx @@ -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: , - content: + content: ( + + ) }, { 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: , + content: } ]; }, []); diff --git a/src/frontend/src/states/SettingsStates.tsx b/src/frontend/src/states/SettingsStates.tsx index 0a93cc235d..83ecbab187 100644 --- a/src/frontend/src/states/SettingsStates.tsx +++ b/src/frontend/src/states/SettingsStates.tsx @@ -123,10 +123,12 @@ export const useUserSettingsState = create((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]); diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts index 0eab513229..3e3938d9c2 100644 --- a/src/frontend/tests/pui_plugins.spec.ts +++ b/src/frontend/tests/pui_plugins.spec.ts @@ -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 diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index 5499c6a64e..5a3c11661c 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -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, {