Merge pull request #2805 from matmair/matmair/issue2385

Plugins for notifications
This commit is contained in:
Oliver 2022-05-06 20:00:56 +10:00 committed by GitHub
commit 9e1d8e52f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 620 additions and 124 deletions

View File

@ -31,6 +31,8 @@ class InvenTreeConfig(AppConfig):
if not isInTestMode(): # pragma: no cover if not isInTestMode(): # pragma: no cover
self.update_exchange_rates() self.update_exchange_rates()
self.collect_notification_methods()
if canAppAccessDatabase() or settings.TESTING_ENV: if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup() self.add_user_on_startup()
@ -197,3 +199,11 @@ class InvenTreeConfig(AppConfig):
# do not try again # do not try again
settings.USER_ADDED = True settings.USER_ADDED = True
def collect_notification_methods(self):
"""
Collect all notification methods
"""
from common.notifications import storage
storage.collect()

View File

@ -24,6 +24,8 @@ from django_q.tasks import async_task
import common.models import common.models
import common.serializers import common.serializers
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from plugin.models import NotificationUserSetting
from plugin.serializers import NotificationUserSettingSerializer
class CsrfExemptMixin(object): class CsrfExemptMixin(object):
@ -145,7 +147,7 @@ class GlobalSettingsPermissions(permissions.BasePermission):
user = request.user user = request.user
return user.is_staff return user.is_staff
except AttributeError: except AttributeError: # pragma: no cover
return False return False
@ -179,7 +181,7 @@ class UserSettingsList(SettingsList):
try: try:
user = self.request.user user = self.request.user
except AttributeError: except AttributeError: # pragma: no cover
return common.models.InvenTreeUserSetting.objects.none() return common.models.InvenTreeUserSetting.objects.none()
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
@ -198,7 +200,7 @@ class UserSettingsPermissions(permissions.BasePermission):
try: try:
user = request.user user = request.user
except AttributeError: except AttributeError: # pragma: no cover
return False return False
return user == obj.user return user == obj.user
@ -219,6 +221,44 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView):
] ]
class NotificationUserSettingsList(SettingsList):
"""
API endpoint for accessing a list of notification user settings objects
"""
queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer
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(generics.RetrieveUpdateAPIView):
"""
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 = [
UserSettingsPermissions,
]
class NotificationList(generics.ListAPIView): class NotificationList(generics.ListAPIView):
queryset = common.models.NotificationMessage.objects.all() queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer serializer_class = common.serializers.NotificationMessageSerializer
@ -344,6 +384,15 @@ settings_api_urls = [
re_path(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'), re_path(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'),
])), ])),
# Notification settings
re_path(r'^notification/', include([
# Notification Settings Detail
re_path(r'^(?P<pk>\d+)/', NotificationUserSettingsDetail.as_view(), name='api-notification-setting-detail'),
# Notification Settings List
re_path(r'^.*$', NotificationUserSettingsList.as_view(), name='api-notifcation-setting-list'),
])),
# Global settings # Global settings
re_path(r'^global/', include([ re_path(r'^global/', include([
# Global Settings Detail # Global Settings Detail

View File

@ -265,6 +265,12 @@ class BaseInvenTreeSetting(models.Model):
filters['plugin'] = plugin filters['plugin'] = plugin
kwargs['plugin'] = plugin kwargs['plugin'] = plugin
# Filter by method
method = kwargs.get('method', None)
if method is not None:
filters['method'] = method
try: try:
setting = settings.filter(**filters).first() setting = settings.filter(**filters).first()
except (ValueError, cls.DoesNotExist): except (ValueError, cls.DoesNotExist):
@ -648,6 +654,87 @@ class BaseInvenTreeSetting(models.Model):
return self.__class__.is_protected(self.key) return self.__class__.is_protected(self.key)
class GenericReferencedSettingClass:
"""
This mixin can be used to add reference keys to static properties
Sample:
```python
class SampleSetting(GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
class Meta:
unique_together = [
('sample', 'key'),
]
REFERENCE_NAME = 'sample'
@classmethod
def get_setting_definition(cls, key, **kwargs):
# mysampledict contains the dict with all settings for this SettingClass - this could also be a dynamic lookup
kwargs['settings'] = mysampledict
return super().get_setting_definition(key, **kwargs)
sample = models.charKey( # the name for this field is the additonal key and must be set in the Meta class an REFERENCE_NAME
max_length=256,
verbose_name=_('sample')
)
```
"""
REFERENCE_NAME = None
def _get_reference(self):
"""
Returns dict that can be used as an argument for kwargs calls.
Helps to make overriden calls generic for simple reuse.
Usage:
```python
some_random_function(argument0, kwarg1=value1, **self._get_reference())
```
"""
return {
self.REFERENCE_NAME: getattr(self, self.REFERENCE_NAME)
}
"""
We override the following class methods,
so that we can pass the modified key instance as an additional argument
"""
def clean(self, **kwargs):
kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME)
super().clean(**kwargs)
def is_bool(self, **kwargs):
kwargs[self.REFERENCE_NAME] = getattr(self, self.REFERENCE_NAME)
return super().is_bool(**kwargs)
@property
def name(self):
return self.__class__.get_setting_name(self.key, **self._get_reference())
@property
def default_value(self):
return self.__class__.get_setting_default(self.key, **self._get_reference())
@property
def description(self):
return self.__class__.get_setting_description(self.key, **self._get_reference())
@property
def units(self):
return self.__class__.get_setting_units(self.key, **self._get_reference())
def choices(self):
return self.__class__.get_setting_choices(self.key, **self._get_reference())
def settings_group_options(): def settings_group_options():
""" """
Build up group tuple for settings based on your choices Build up group tuple for settings based on your choices
@ -1299,14 +1386,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
'NOTIFICATION_SEND_EMAILS': {
'name': _('Enable email notifications'),
'description': _('Allow sending of emails for event notifications'),
'default': True,
'validator': bool,
},
'LABEL_ENABLE': { 'LABEL_ENABLE': {
'name': _('Enable label printing'), 'name': _('Enable label printing'),
'description': _('Enable label printing from the web interface'), 'description': _('Enable label printing from the web interface'),
@ -1458,7 +1537,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
) )
@classmethod @classmethod
def get_setting_object(cls, key, user): def get_setting_object(cls, key, user=None):
return super().get_setting_object(key, user=user) return super().get_setting_object(key, user=user)
def validate_unique(self, exclude=None, **kwargs): def validate_unique(self, exclude=None, **kwargs):

View File

@ -1,29 +1,28 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from django.template.loader import render_to_string
from allauth.account.models import EmailAddress
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from common.models import NotificationEntry, NotificationMessage from common.models import NotificationEntry, NotificationMessage
from common.models import InvenTreeUserSetting from plugin import registry
from plugin.models import NotificationUserSetting
import InvenTree.tasks
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
# region methods
class NotificationMethod: class NotificationMethod:
""" """
Base class for notification methods Base class for notification methods
""" """
METHOD_NAME = '' METHOD_NAME = ''
METHOD_ICON = None
CONTEXT_BUILTIN = ['name', 'message', ] CONTEXT_BUILTIN = ['name', 'message', ]
CONTEXT_EXTRA = [] CONTEXT_EXTRA = []
GLOBAL_SETTING = None
USER_SETTING = None
def __init__(self, obj, category, targets, context) -> None: def __init__(self, obj, category, targets, context) -> None:
# Check if a sending fnc is defined # Check if a sending fnc is defined
@ -34,6 +33,11 @@ class NotificationMethod:
if self.METHOD_NAME in ('', None): if self.METHOD_NAME in ('', None):
raise NotImplementedError(f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME') raise NotImplementedError(f'The NotificationMethod {self.__class__} did not provide a METHOD_NAME')
# Check if plugin is disabled - if so do not gather targets etc.
if self.global_setting_disable():
self.targets = None
return
# Define arguments # Define arguments
self.obj = obj self.obj = obj
self.category = category self.category = category
@ -84,12 +88,40 @@ class NotificationMethod:
def setup(self): def setup(self):
return True return True
# def send(self, targets)
# def send_bulk(self)
def cleanup(self): def cleanup(self):
return True 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.plugins.get(plg_cls.PLUGIN_NAME.lower())
if plg_instance and not plg_instance.get_setting(self.GLOBAL_SETTING):
return True
# Lets go!
return False
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): class SingleNotificationMethod(NotificationMethod):
def send(self, target): def send(self, target):
@ -99,41 +131,59 @@ class SingleNotificationMethod(NotificationMethod):
class BulkNotificationMethod(NotificationMethod): class BulkNotificationMethod(NotificationMethod):
def send_bulk(self): def send_bulk(self):
raise NotImplementedError('The `send` method must be overriden!') raise NotImplementedError('The `send` method must be overriden!')
# endregion
class EmailNotification(BulkNotificationMethod): class MethodStorageClass:
METHOD_NAME = 'mail' liste = None
CONTEXT_EXTRA = [ user_settings = {}
('template', ),
('template', 'html', ),
('template', 'subject', ),
]
def get_targets(self): def collect(self, selected_classes=None):
""" logger.info('collecting notification methods')
Return a list of target email addresses, current_method = inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
only for users which allow email notifications
"""
allowed_users = [] # for testing selective loading is made available
if selected_classes:
current_method = [item for item in current_method if item is selected_classes]
for user in self.targets: # make sure only one of each method is added
allows_emails = InvenTreeUserSetting.get_setting('NOTIFICATION_SEND_EMAILS', user=user) 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
filtered_list[ref] = item
if allows_emails: storage.liste = list(filtered_list.values())
allowed_users.append(user) logger.info(f'found {len(storage.liste)} notification methods')
return EmailAddress.objects.filter( def get_usersettings(self, user):
user__in=allowed_users, methods = []
for item in storage.liste:
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,
) )
def send_bulk(self): # save definition
html_message = render_to_string(self.context['template']['html'], self.context) methods.append({
targets = self.get_targets().values_list('email', flat=True) 'key': new_key,
'icon': getattr(item, 'METHOD_ICON', ''),
'method': item.METHOD_NAME,
})
return methods
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
return True IGNORED_NOTIFICATION_CLS = set([
SingleNotificationMethod,
BulkNotificationMethod,
])
storage = MethodStorageClass()
class UIMessageNotification(SingleNotificationMethod): class UIMessageNotification(SingleNotificationMethod):
@ -198,9 +248,11 @@ def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
# Collect possible methods # Collect possible methods
if delivery_methods is None: if delivery_methods is None:
delivery_methods = inheritors(NotificationMethod) delivery_methods = storage.liste
else:
delivery_methods = (delivery_methods - IGNORED_NOTIFICATION_CLS)
for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]: for method in delivery_methods:
logger.info(f"Triggering method '{method.METHOD_NAME}'") logger.info(f"Triggering method '{method.METHOD_NAME}'")
try: try:
deliver_notification(method, obj, category, targets, context) deliver_notification(method, obj, category, targets, context)

View File

@ -99,6 +99,43 @@ class UserSettingsSerializer(SettingsSerializer):
] ]
class GenericReferencedSettingSerializer(SettingsSerializer):
"""
Serializer for a GenericReferencedSetting model
Args:
MODEL: model class for the serializer
EXTRA_FIELDS: fields that need to be appended to the serializer
field must also be defined in the custom class
"""
MODEL = None
EXTRA_FIELDS = None
def __init__(self, *args, **kwargs):
"""Init overrides the Meta class to make it dynamic"""
class CustomMeta:
"""Scaffold for custom Meta class"""
fields = [
'pk',
'key',
'value',
'name',
'description',
'type',
'choices',
]
# set Meta class
self.Meta = CustomMeta
self.Meta.model = self.MODEL
# extend the fields
self.Meta.fields.extend(self.EXTRA_FIELDS)
# resume operations
super().__init__(*args, **kwargs)
class NotificationMessageSerializer(InvenTreeModelSerializer): class NotificationMessageSerializer(InvenTreeModelSerializer):
""" """
Serializer for the InvenTreeUserSetting model Serializer for the InvenTreeUserSetting model

View File

@ -1,8 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from common.notifications import NotificationMethod, SingleNotificationMethod, BulkNotificationMethod from common.notifications import NotificationMethod, SingleNotificationMethod, BulkNotificationMethod, storage
from plugin.models import NotificationUserSetting
from part.test_part import BaseNotificationIntegrationTest from part.test_part import BaseNotificationIntegrationTest
import plugin.templatetags.plugin_extras as plugin_tags
class BaseNotificationTests(BaseNotificationIntegrationTest): class BaseNotificationTests(BaseNotificationIntegrationTest):
@ -36,15 +38,6 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
def send(self): def send(self):
"""a comment so we do not need a pass""" """a comment so we do not need a pass"""
class WrongDeliveryImplementation(SingleNotificationMethod):
METHOD_NAME = 'WrongDeliveryImplementation'
def get_targets(self):
return [1, ]
def send(self, target):
return False
# no send / send bulk # no send / send bulk
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
FalseNotificationMethod('', '', '', '', ) FalseNotificationMethod('', '', '', '', )
@ -77,13 +70,16 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
def send(self, target): def send(self, target):
raise KeyError('This could be any error') raise KeyError('This could be any error')
self._notification_run() self._notification_run(ErrorImplementation)
class BulkNotificationMethodTests(BaseNotificationIntegrationTest): class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
def test_BulkNotificationMethod(self): def test_BulkNotificationMethod(self):
"""ensure the implementation requirements are tested""" """
Ensure the implementation requirements are tested.
NotImplementedError needs to raise if the send_bulk() method is not set.
"""
class WrongImplementation(BulkNotificationMethod): class WrongImplementation(BulkNotificationMethod):
METHOD_NAME = 'WrongImplementationBulk' METHOD_NAME = 'WrongImplementationBulk'
@ -92,13 +88,16 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
return [1, ] return [1, ]
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self._notification_run() self._notification_run(WrongImplementation)
class SingleNotificationMethodTests(BaseNotificationIntegrationTest): class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
def test_SingleNotificationMethod(self): def test_SingleNotificationMethod(self):
"""ensure the implementation requirements are tested""" """
Ensure the implementation requirements are tested.
NotImplementedError needs to raise if the send() method is not set.
"""
class WrongImplementation(SingleNotificationMethod): class WrongImplementation(SingleNotificationMethod):
METHOD_NAME = 'WrongImplementationSingle' METHOD_NAME = 'WrongImplementationSingle'
@ -107,6 +106,51 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
return [1, ] return [1, ]
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self._notification_run() self._notification_run(WrongImplementation)
# A integration test for notifications is provided in test_part.PartNotificationTest # A integration test for notifications is provided in test_part.PartNotificationTest
class NotificationUserSettingTests(BaseNotificationIntegrationTest):
""" Tests for NotificationUserSetting """
def setUp(self):
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 thorugh notification
self._notification_run(SampleImplementation)
# make sure the array fits
array = storage.get_usersettings(self.user)
setting = NotificationUserSetting.objects.all().first()
# assertions for settings
self.assertEqual(setting.name, 'Enable test notifications')
self.assertEqual(setting.default_value, True)
self.assertEqual(setting.description, 'Allow sending of test for event notifications')
self.assertEqual(setting.units, 'alpha')
# test tag and array
self.assertEqual(plugin_tags.notification_settings_list({'user': self.user}), array)
self.assertEqual(array[0]['key'], 'NOTIFICATION_METHOD_TEST')
self.assertEqual(array[0]['method'], 'test')

View File

@ -10,6 +10,7 @@ from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from plugin.models import NotificationUserSetting
from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
from .api import WebhookView from .api import WebhookView
@ -377,6 +378,31 @@ class UserSettingsApiTest(InvenTreeAPITestCase):
) )
class NotificationUserSettingsApiTest(InvenTreeAPITestCase):
"""Tests for the notification user settings API"""
def test_api_list(self):
"""Test list URL"""
url = reverse('api-notifcation-setting-list')
self.get(url, expected_code=200)
def test_setting(self):
"""Test the string name for NotificationUserSetting"""
test_setting = NotificationUserSetting.get_setting_object('NOTIFICATION_METHOD_MAIL', user=self.user)
self.assertEqual(str(test_setting), 'NOTIFICATION_METHOD_MAIL (for testuser): ')
class PluginSettingsApiTest(InvenTreeAPITestCase):
"""Tests for the plugin settings API"""
def test_api_list(self):
"""Test list URL"""
url = reverse('api-plugin-setting-list')
self.get(url, expected_code=200)
class WebhookMessageTests(TestCase): class WebhookMessageTests(TestCase):
def setUp(self): def setUp(self):
self.endpoint_def = WebhookEndpoint.objects.create() self.endpoint_def = WebhookEndpoint.objects.create()
@ -489,7 +515,7 @@ class WebhookMessageTests(TestCase):
assert message.body == {"this": "is a message"} assert message.body == {"this": "is a message"}
class NotificationTest(TestCase): class NotificationTest(InvenTreeAPITestCase):
def test_check_notification_entries(self): def test_check_notification_entries(self):
@ -508,6 +534,11 @@ class NotificationTest(TestCase):
self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta)) self.assertTrue(NotificationEntry.check_recent('test.notification', 1, delta))
def test_api_list(self):
"""Test list URL"""
url = reverse('api-notifications-list')
self.get(url, expected_code=200)
class LoadingTest(TestCase): class LoadingTest(TestCase):
""" """

View File

@ -28,7 +28,7 @@ import InvenTree.helpers
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
from common.settings import currency_code_default from common.settings import currency_code_default
from plugin.models import PluginSetting from plugin.models import PluginSetting, NotificationUserSetting
register = template.Library() register = template.Library()
@ -313,6 +313,9 @@ def setting_object(key, *args, **kwargs):
return PluginSetting.get_setting_object(key, plugin=plugin) return PluginSetting.get_setting_object(key, plugin=plugin)
if 'method' in kwargs:
return NotificationUserSetting.get_setting_object(key, user=kwargs['user'], method=kwargs['method'])
if 'user' in kwargs: if 'user' in kwargs:
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user']) return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
@ -326,6 +329,8 @@ def settings_value(key, *args, **kwargs):
""" """
if 'user' in kwargs: if 'user' in kwargs:
if not kwargs['user']:
return InvenTreeUserSetting.get_setting(key)
return InvenTreeUserSetting.get_setting(key, user=kwargs['user']) return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
return InvenTreeSetting.get_setting(key) return InvenTreeSetting.get_setting(key)

View File

@ -20,6 +20,7 @@ import part.settings
from InvenTree import version from InvenTree import version
from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage
from common.notifications import storage, UIMessageNotification
class TemplateTagTest(TestCase): class TemplateTagTest(TestCase):
@ -565,7 +566,14 @@ class BaseNotificationIntegrationTest(TestCase):
# Define part that will be tested # Define part that will be tested
self.part = Part.objects.get(name='R_2K2_0805') self.part = Part.objects.get(name='R_2K2_0805')
def _notification_run(self): 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)
# There should be no notification runs # There should be no notification runs
self.assertEqual(NotificationEntry.objects.all().count(), 0) self.assertEqual(NotificationEntry.objects.all().count(), 0)
@ -588,7 +596,7 @@ class PartNotificationTest(BaseNotificationIntegrationTest):
""" Integration test for part notifications """ """ Integration test for part notifications """
def test_notification(self): def test_notification(self):
self._notification_run() self._notification_run(UIMessageNotification)
# There should be 1 notification message right now # There should be 1 notification message right now
self.assertEqual(NotificationMessage.objects.all().count(), 1) self.assertEqual(NotificationMessage.objects.all().count(), 1)

View File

@ -70,4 +70,20 @@ class PluginConfigAdmin(admin.ModelAdmin):
inlines = [PluginSettingInline, ] inlines = [PluginSettingInline, ]
class NotificationUserSettingAdmin(admin.ModelAdmin):
"""
Admin class for NotificationUserSetting
"""
model = models.NotificationUserSetting
read_only_fields = [
'key',
]
def has_add_permission(self, request):
return False
admin.site.register(models.PluginConfig, PluginConfigAdmin) admin.site.register(models.PluginConfig, PluginConfigAdmin)
admin.site.register(models.NotificationUserSetting, NotificationUserSettingAdmin)

View File

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""Core set of Notifications as a Plugin"""
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _
from allauth.account.models import EmailAddress
from plugin import IntegrationPluginBase
from plugin.mixins import BulkNotificationMethod, SettingsMixin
import InvenTree.tasks
class PlgMixin:
def get_plugin(self):
return CoreNotificationsPlugin
class CoreNotificationsPlugin(SettingsMixin, IntegrationPluginBase):
"""
Core notification methods for InvenTree
"""
PLUGIN_NAME = "CoreNotificationsPlugin"
AUTHOR = _('InvenTree contributors')
DESCRIPTION = _('Integrated outgoing notificaton methods')
SETTINGS = {
'ENABLE_NOTIFICATION_EMAILS': {
'name': _('Enable email notifications'),
'description': _('Allow sending of emails for event notifications'),
'default': False,
'validator': bool,
},
}
class EmailNotification(PlgMixin, BulkNotificationMethod):
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:
allows_emails = self.usersetting(user)
if allows_emails:
allowed_users.append(user)
return EmailAddress.objects.filter(
user__in=allowed_users,
)
def send_bulk(self):
html_message = render_to_string(self.context['template']['html'], self.context)
targets = self.targets.values_list('email', flat=True)
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
return True

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from plugin.models import NotificationUserSetting
from part.test_part import BaseNotificationIntegrationTest
from plugin.builtin.integration.core_notifications import CoreNotificationsPlugin
from plugin import registry
class CoreNotificationTestTests(BaseNotificationIntegrationTest):
def test_email(self):
"""
Ensure that the email notifications run
"""
# enable plugin and set mail setting to true
plugin = registry.plugins.get('corenotificationsplugin')
plugin.set_setting('ENABLE_NOTIFICATION_EMAILS', True)
NotificationUserSetting.set_setting(
key='NOTIFICATION_METHOD_MAIL',
value=True,
change_user=self.user,
user=self.user,
method=CoreNotificationsPlugin.EmailNotification.METHOD_NAME
)
# run through
self._notification_run(CoreNotificationsPlugin.EmailNotification)

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2.12 on 2022-04-03 23:38
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', '0004_alter_pluginsetting_key'),
]
operations = [
migrations.CreateModel(
name='NotificationUserSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(help_text='Settings key (must be unique - case insensitive)', max_length=50)),
('value', models.CharField(blank=True, help_text='Settings value', max_length=200)),
('method', models.CharField(max_length=255, verbose_name='Method')),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'unique_together': {('method', 'user', 'key')},
},
),
]

View File

@ -3,6 +3,7 @@ Utility class to enable simpler imports
""" """
from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from common.notifications import SingleNotificationMethod, BulkNotificationMethod
from ..builtin.action.mixins import ActionMixin from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin from ..builtin.barcode.mixins import BarcodeMixin
@ -18,4 +19,6 @@ __all__ = [
'UrlsMixin', 'UrlsMixin',
'ActionMixin', 'ActionMixin',
'BarcodeMixin', 'BarcodeMixin',
'SingleNotificationMethod',
'BulkNotificationMethod',
] ]

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db import models from django.db import models
from django.contrib.auth.models import User
import common.models import common.models
@ -101,7 +102,7 @@ class PluginConfig(models.Model):
return ret return ret
class PluginSetting(common.models.BaseInvenTreeSetting): class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
""" """
This model represents settings for individual plugins This model represents settings for individual plugins
""" """
@ -111,41 +112,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
('plugin', 'key'), ('plugin', 'key'),
] ]
def clean(self, **kwargs): REFERENCE_NAME = 'plugin'
kwargs['plugin'] = self.plugin
super().clean(**kwargs)
"""
We override the following class methods,
so that we can pass the plugin instance
"""
def is_bool(self, **kwargs):
kwargs['plugin'] = self.plugin
return super().is_bool(**kwargs)
@property
def name(self):
return self.__class__.get_setting_name(self.key, plugin=self.plugin)
@property
def default_value(self):
return self.__class__.get_setting_default(self.key, plugin=self.plugin)
@property
def description(self):
return self.__class__.get_setting_description(self.key, plugin=self.plugin)
@property
def units(self):
return self.__class__.get_setting_units(self.key, plugin=self.plugin)
def choices(self):
return self.__class__.get_setting_choices(self.key, plugin=self.plugin)
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
@ -182,3 +149,40 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
verbose_name=_('Plugin'), verbose_name=_('Plugin'),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
class NotificationUserSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
"""
This model represents notification settings for a user
"""
class Meta:
unique_together = [
('method', 'user', 'key'),
]
REFERENCE_NAME = 'method'
@classmethod
def get_setting_definition(cls, key, **kwargs):
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'),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
blank=True, null=True,
verbose_name=_('User'),
help_text=_('User'),
)
def __str__(self) -> str:
return f'{self.key} (for {self.user}): {self.value}'

View File

@ -523,7 +523,10 @@ class PluginsRegistry:
# check all models # check all models
for model in app_config.get_models(): for model in app_config.get_models():
# remove model from admin site # remove model from admin site
try:
admin.site.unregister(model) admin.site.unregister(model)
except: # pragma: no cover
pass
models += [model._meta.model_name] models += [model._meta.model_name]
except LookupError: # pragma: no cover except LookupError: # pragma: no cover
# if an error occurs the app was never loaded right -> so nothing to do anymore # if an error occurs the app was never loaded right -> so nothing to do anymore

View File

@ -15,8 +15,8 @@ from django.utils import timezone
from rest_framework import serializers from rest_framework import serializers
from plugin.models import PluginConfig, PluginSetting from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
from common.serializers import SettingsSerializer from common.serializers import GenericReferencedSettingSerializer
class PluginConfigSerializer(serializers.ModelSerializer): class PluginConfigSerializer(serializers.ModelSerializer):
@ -128,22 +128,25 @@ class PluginConfigInstallSerializer(serializers.Serializer):
return ret return ret
class PluginSettingSerializer(SettingsSerializer): class PluginSettingSerializer(GenericReferencedSettingSerializer):
""" """
Serializer for the PluginSetting model Serializer for the PluginSetting model
""" """
plugin = serializers.PrimaryKeyRelatedField(read_only=True) MODEL = PluginSetting
EXTRA_FIELDS = [
class Meta:
model = PluginSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'type',
'choices',
'plugin', 'plugin',
] ]
plugin = serializers.PrimaryKeyRelatedField(read_only=True)
class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
"""
Serializer for the PluginSetting model
"""
MODEL = NotificationUserSetting
EXTRA_FIELDS = ['method', ]
method = serializers.CharField(read_only=True)

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from plugin import registry from plugin import registry
from common.notifications import storage
register = template.Library() register = template.Library()
@ -73,3 +73,11 @@ def plugin_errors(*args, **kwargs):
All plugin errors in the current session All plugin errors in the current session
""" """
return registry.errors return registry.errors
@register.simple_tag(takes_context=True)
def notification_settings_list(context, *args, **kwargs):
"""
List of all user notification settings
"""
return storage.get_usersettings(user=context.get('user', None))

View File

@ -5,6 +5,8 @@
{% setting_object key plugin=plugin as setting %} {% setting_object key plugin=plugin as setting %}
{% elif user_setting %} {% elif user_setting %}
{% setting_object key user=request.user as setting %} {% setting_object key user=request.user as setting %}
{% elif notification_setting %}
{% setting_object key method=method user=request.user as setting %}
{% else %} {% else %}
{% setting_object key as setting %} {% setting_object key as setting %}
{% endif %} {% endif %}
@ -22,7 +24,7 @@
<td> <td>
{% if setting.is_bool %} {% if setting.is_bool %}
<div class='form-check form-switch'> <div class='form-check form-switch'>
<input class='form-check-input boolean-setting' fieldname='{{ setting.key.upper }}' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' id='setting-value-{{ setting.key.upper }}' type='checkbox' {% if setting.as_bool %}checked=''{% endif %} {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}> <input class='form-check-input boolean-setting' fieldname='{{ setting.key.upper }}' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' id='setting-value-{{ setting.key.upper }}' type='checkbox' {% if setting.as_bool %}checked=''{% endif %} {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}{% if notification_setting %}notification='{{request.user.id}}'{% endif %}>
</div> </div>
{% else %} {% else %}
<div id='setting-{{ setting.pk }}'> <div id='setting-{{ setting.pk }}'>

View File

@ -70,6 +70,7 @@ $('table').find('.boolean-setting').change(function() {
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var plugin = $(this).attr('plugin'); var plugin = $(this).attr('plugin');
var user = $(this).attr('user'); var user = $(this).attr('user');
var notification = $(this).attr('notification');
var checked = this.checked; var checked = this.checked;
@ -80,6 +81,8 @@ $('table').find('.boolean-setting').change(function() {
url = `/api/plugin/settings/${pk}/`; url = `/api/plugin/settings/${pk}/`;
} else if (user) { } else if (user) {
url = `/api/settings/user/${pk}/`; url = `/api/settings/user/${pk}/`;
} else if (notification) {
url = `/api/settings/notification/${pk}/`;
} }
inventreePut( inventreePut(

View File

@ -2,6 +2,7 @@
{% load i18n %} {% load i18n %}
{% load inventree_extras %} {% load inventree_extras %}
{% load plugin_extras %}
{% block label %}user-notifications{% endblock label %} {% block label %}user-notifications{% endblock label %}
@ -12,7 +13,10 @@
<div class='row'> <div class='row'>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="NOTIFICATION_SEND_EMAILS" icon='fa-envelope' user_setting=True %} {% notification_settings_list as settings %}
{% for setting in settings %}
{% include "InvenTree/settings/setting.html" with key=setting.key icon=setting.icon method=setting.method notification_setting=True %}
{% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -78,6 +78,7 @@ class RuleSet(models.Model):
'otp_static_staticdevice', 'otp_static_staticdevice',
'plugin_pluginconfig', 'plugin_pluginconfig',
'plugin_pluginsetting', 'plugin_pluginsetting',
'plugin_notificationusersetting',
], ],
'part_category': [ 'part_category': [
'part_partcategory', 'part_partcategory',