mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2805 from matmair/matmair/issue2385
Plugins for notifications
This commit is contained in:
commit
9e1d8e52f3
@ -31,6 +31,8 @@ class InvenTreeConfig(AppConfig):
|
||||
if not isInTestMode(): # pragma: no cover
|
||||
self.update_exchange_rates()
|
||||
|
||||
self.collect_notification_methods()
|
||||
|
||||
if canAppAccessDatabase() or settings.TESTING_ENV:
|
||||
self.add_user_on_startup()
|
||||
|
||||
@ -197,3 +199,11 @@ class InvenTreeConfig(AppConfig):
|
||||
|
||||
# do not try again
|
||||
settings.USER_ADDED = True
|
||||
|
||||
def collect_notification_methods(self):
|
||||
"""
|
||||
Collect all notification methods
|
||||
"""
|
||||
from common.notifications import storage
|
||||
|
||||
storage.collect()
|
||||
|
@ -24,6 +24,8 @@ from django_q.tasks import async_task
|
||||
import common.models
|
||||
import common.serializers
|
||||
from InvenTree.helpers import inheritors
|
||||
from plugin.models import NotificationUserSetting
|
||||
from plugin.serializers import NotificationUserSettingSerializer
|
||||
|
||||
|
||||
class CsrfExemptMixin(object):
|
||||
@ -145,7 +147,7 @@ class GlobalSettingsPermissions(permissions.BasePermission):
|
||||
user = request.user
|
||||
|
||||
return user.is_staff
|
||||
except AttributeError:
|
||||
except AttributeError: # pragma: no cover
|
||||
return False
|
||||
|
||||
|
||||
@ -179,7 +181,7 @@ class UserSettingsList(SettingsList):
|
||||
|
||||
try:
|
||||
user = self.request.user
|
||||
except AttributeError:
|
||||
except AttributeError: # pragma: no cover
|
||||
return common.models.InvenTreeUserSetting.objects.none()
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
@ -198,7 +200,7 @@ class UserSettingsPermissions(permissions.BasePermission):
|
||||
|
||||
try:
|
||||
user = request.user
|
||||
except AttributeError:
|
||||
except AttributeError: # pragma: no cover
|
||||
return False
|
||||
|
||||
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):
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
serializer_class = common.serializers.NotificationMessageSerializer
|
||||
@ -344,6 +384,15 @@ settings_api_urls = [
|
||||
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
|
||||
re_path(r'^global/', include([
|
||||
# Global Settings Detail
|
||||
|
@ -265,6 +265,12 @@ class BaseInvenTreeSetting(models.Model):
|
||||
filters['plugin'] = plugin
|
||||
kwargs['plugin'] = plugin
|
||||
|
||||
# Filter by method
|
||||
method = kwargs.get('method', None)
|
||||
|
||||
if method is not None:
|
||||
filters['method'] = method
|
||||
|
||||
try:
|
||||
setting = settings.filter(**filters).first()
|
||||
except (ValueError, cls.DoesNotExist):
|
||||
@ -648,6 +654,87 @@ class BaseInvenTreeSetting(models.Model):
|
||||
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():
|
||||
"""
|
||||
Build up group tuple for settings based on your choices
|
||||
@ -1299,14 +1386,6 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'NOTIFICATION_SEND_EMAILS': {
|
||||
'name': _('Enable email notifications'),
|
||||
'description': _('Allow sending of emails for event notifications'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'LABEL_ENABLE': {
|
||||
'name': _('Enable label printing'),
|
||||
'description': _('Enable label printing from the web interface'),
|
||||
@ -1458,7 +1537,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_setting_object(cls, key, user):
|
||||
def get_setting_object(cls, key, user=None):
|
||||
return super().get_setting_object(key, user=user)
|
||||
|
||||
def validate_unique(self, exclude=None, **kwargs):
|
||||
|
@ -1,29 +1,28 @@
|
||||
import logging
|
||||
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.ready import isImportingData
|
||||
from common.models import NotificationEntry, NotificationMessage
|
||||
from common.models import InvenTreeUserSetting
|
||||
|
||||
import InvenTree.tasks
|
||||
from plugin import registry
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
# region methods
|
||||
class NotificationMethod:
|
||||
"""
|
||||
Base class for notification methods
|
||||
"""
|
||||
|
||||
METHOD_NAME = ''
|
||||
METHOD_ICON = None
|
||||
CONTEXT_BUILTIN = ['name', 'message', ]
|
||||
CONTEXT_EXTRA = []
|
||||
GLOBAL_SETTING = None
|
||||
USER_SETTING = None
|
||||
|
||||
def __init__(self, obj, category, targets, context) -> None:
|
||||
# Check if a sending fnc is defined
|
||||
@ -34,6 +33,11 @@ class NotificationMethod:
|
||||
if self.METHOD_NAME in ('', None):
|
||||
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
|
||||
self.obj = obj
|
||||
self.category = category
|
||||
@ -84,12 +88,40 @@ class NotificationMethod:
|
||||
def setup(self):
|
||||
return True
|
||||
|
||||
# def send(self, targets)
|
||||
# def send_bulk(self)
|
||||
|
||||
def cleanup(self):
|
||||
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):
|
||||
def send(self, target):
|
||||
@ -99,41 +131,59 @@ class SingleNotificationMethod(NotificationMethod):
|
||||
class BulkNotificationMethod(NotificationMethod):
|
||||
def send_bulk(self):
|
||||
raise NotImplementedError('The `send` method must be overriden!')
|
||||
# endregion
|
||||
|
||||
|
||||
class EmailNotification(BulkNotificationMethod):
|
||||
METHOD_NAME = 'mail'
|
||||
CONTEXT_EXTRA = [
|
||||
('template', ),
|
||||
('template', 'html', ),
|
||||
('template', 'subject', ),
|
||||
]
|
||||
class MethodStorageClass:
|
||||
liste = None
|
||||
user_settings = {}
|
||||
|
||||
def get_targets(self):
|
||||
"""
|
||||
Return a list of target email addresses,
|
||||
only for users which allow email notifications
|
||||
"""
|
||||
def collect(self, selected_classes=None):
|
||||
logger.info('collecting notification methods')
|
||||
current_method = inheritors(NotificationMethod) - IGNORED_NOTIFICATION_CLS
|
||||
|
||||
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:
|
||||
allows_emails = InvenTreeUserSetting.get_setting('NOTIFICATION_SEND_EMAILS', user=user)
|
||||
# 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
|
||||
filtered_list[ref] = item
|
||||
|
||||
if allows_emails:
|
||||
allowed_users.append(user)
|
||||
storage.liste = list(filtered_list.values())
|
||||
logger.info(f'found {len(storage.liste)} notification methods')
|
||||
|
||||
return EmailAddress.objects.filter(
|
||||
user__in=allowed_users,
|
||||
)
|
||||
def get_usersettings(self, user):
|
||||
methods = []
|
||||
for item in storage.liste:
|
||||
if item.USER_SETTING:
|
||||
new_key = f'NOTIFICATION_METHOD_{item.METHOD_NAME.upper()}'
|
||||
|
||||
def send_bulk(self):
|
||||
html_message = render_to_string(self.context['template']['html'], self.context)
|
||||
targets = self.get_targets().values_list('email', flat=True)
|
||||
# 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,
|
||||
)
|
||||
|
||||
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
|
||||
# save definition
|
||||
methods.append({
|
||||
'key': new_key,
|
||||
'icon': getattr(item, 'METHOD_ICON', ''),
|
||||
'method': item.METHOD_NAME,
|
||||
})
|
||||
return methods
|
||||
|
||||
return True
|
||||
|
||||
IGNORED_NOTIFICATION_CLS = set([
|
||||
SingleNotificationMethod,
|
||||
BulkNotificationMethod,
|
||||
])
|
||||
storage = MethodStorageClass()
|
||||
|
||||
|
||||
class UIMessageNotification(SingleNotificationMethod):
|
||||
@ -198,9 +248,11 @@ def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
|
||||
|
||||
# Collect possible methods
|
||||
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}'")
|
||||
try:
|
||||
deliver_notification(method, obj, category, targets, context)
|
||||
|
@ -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):
|
||||
"""
|
||||
Serializer for the InvenTreeUserSetting model
|
||||
|
@ -1,8 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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
|
||||
import plugin.templatetags.plugin_extras as plugin_tags
|
||||
|
||||
|
||||
class BaseNotificationTests(BaseNotificationIntegrationTest):
|
||||
@ -36,15 +38,6 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
|
||||
def send(self):
|
||||
"""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
|
||||
with self.assertRaises(NotImplementedError):
|
||||
FalseNotificationMethod('', '', '', '', )
|
||||
@ -77,13 +70,16 @@ class BaseNotificationTests(BaseNotificationIntegrationTest):
|
||||
def send(self, target):
|
||||
raise KeyError('This could be any error')
|
||||
|
||||
self._notification_run()
|
||||
self._notification_run(ErrorImplementation)
|
||||
|
||||
|
||||
class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||
|
||||
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):
|
||||
METHOD_NAME = 'WrongImplementationBulk'
|
||||
@ -92,13 +88,16 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||
return [1, ]
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self._notification_run()
|
||||
self._notification_run(WrongImplementation)
|
||||
|
||||
|
||||
class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||
|
||||
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):
|
||||
METHOD_NAME = 'WrongImplementationSingle'
|
||||
@ -107,6 +106,51 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||
return [1, ]
|
||||
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self._notification_run()
|
||||
self._notification_run(WrongImplementation)
|
||||
|
||||
# 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')
|
||||
|
@ -10,6 +10,7 @@ from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.helpers import str2bool
|
||||
from plugin.models import NotificationUserSetting
|
||||
|
||||
from .models import InvenTreeSetting, InvenTreeUserSetting, WebhookEndpoint, WebhookMessage, NotificationEntry
|
||||
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):
|
||||
def setUp(self):
|
||||
self.endpoint_def = WebhookEndpoint.objects.create()
|
||||
@ -489,7 +515,7 @@ class WebhookMessageTests(TestCase):
|
||||
assert message.body == {"this": "is a message"}
|
||||
|
||||
|
||||
class NotificationTest(TestCase):
|
||||
class NotificationTest(InvenTreeAPITestCase):
|
||||
|
||||
def test_check_notification_entries(self):
|
||||
|
||||
@ -508,6 +534,11 @@ class NotificationTest(TestCase):
|
||||
|
||||
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):
|
||||
"""
|
||||
|
@ -28,7 +28,7 @@ import InvenTree.helpers
|
||||
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
|
||||
from common.settings import currency_code_default
|
||||
|
||||
from plugin.models import PluginSetting
|
||||
from plugin.models import PluginSetting, NotificationUserSetting
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@ -313,6 +313,9 @@ def setting_object(key, *args, **kwargs):
|
||||
|
||||
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:
|
||||
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
|
||||
|
||||
@ -326,6 +329,8 @@ def settings_value(key, *args, **kwargs):
|
||||
"""
|
||||
|
||||
if 'user' in kwargs:
|
||||
if not kwargs['user']:
|
||||
return InvenTreeUserSetting.get_setting(key)
|
||||
return InvenTreeUserSetting.get_setting(key, user=kwargs['user'])
|
||||
|
||||
return InvenTreeSetting.get_setting(key)
|
||||
|
@ -20,6 +20,7 @@ import part.settings
|
||||
|
||||
from InvenTree import version
|
||||
from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage
|
||||
from common.notifications import storage, UIMessageNotification
|
||||
|
||||
|
||||
class TemplateTagTest(TestCase):
|
||||
@ -565,8 +566,15 @@ class BaseNotificationIntegrationTest(TestCase):
|
||||
# Define part that will be tested
|
||||
self.part = Part.objects.get(name='R_2K2_0805')
|
||||
|
||||
def _notification_run(self):
|
||||
# There should be no notification runs
|
||||
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
|
||||
self.assertEqual(NotificationEntry.objects.all().count(), 0)
|
||||
|
||||
# Test that notifications run through without errors
|
||||
@ -588,7 +596,7 @@ class PartNotificationTest(BaseNotificationIntegrationTest):
|
||||
""" Integration test for part notifications """
|
||||
|
||||
def test_notification(self):
|
||||
self._notification_run()
|
||||
self._notification_run(UIMessageNotification)
|
||||
|
||||
# There should be 1 notification message right now
|
||||
self.assertEqual(NotificationMessage.objects.all().count(), 1)
|
||||
|
@ -70,4 +70,20 @@ class PluginConfigAdmin(admin.ModelAdmin):
|
||||
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.NotificationUserSetting, NotificationUserSettingAdmin)
|
||||
|
76
InvenTree/plugin/builtin/integration/core_notifications.py
Normal file
76
InvenTree/plugin/builtin/integration/core_notifications.py
Normal 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
|
@ -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)
|
29
InvenTree/plugin/migrations/0005_notificationusersetting.py
Normal file
29
InvenTree/plugin/migrations/0005_notificationusersetting.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -3,6 +3,7 @@ Utility class to enable simpler imports
|
||||
"""
|
||||
|
||||
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.barcode.mixins import BarcodeMixin
|
||||
@ -18,4 +19,6 @@ __all__ = [
|
||||
'UrlsMixin',
|
||||
'ActionMixin',
|
||||
'BarcodeMixin',
|
||||
'SingleNotificationMethod',
|
||||
'BulkNotificationMethod',
|
||||
]
|
||||
|
@ -7,6 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import common.models
|
||||
|
||||
@ -101,7 +102,7 @@ class PluginConfig(models.Model):
|
||||
return ret
|
||||
|
||||
|
||||
class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
class PluginSetting(common.models.GenericReferencedSettingClass, common.models.BaseInvenTreeSetting):
|
||||
"""
|
||||
This model represents settings for individual plugins
|
||||
"""
|
||||
@ -111,41 +112,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
('plugin', 'key'),
|
||||
]
|
||||
|
||||
def clean(self, **kwargs):
|
||||
|
||||
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)
|
||||
REFERENCE_NAME = 'plugin'
|
||||
|
||||
@classmethod
|
||||
def get_setting_definition(cls, key, **kwargs):
|
||||
@ -182,3 +149,40 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||
verbose_name=_('Plugin'),
|
||||
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}'
|
||||
|
@ -523,7 +523,10 @@ class PluginsRegistry:
|
||||
# check all models
|
||||
for model in app_config.get_models():
|
||||
# remove model from admin site
|
||||
admin.site.unregister(model)
|
||||
try:
|
||||
admin.site.unregister(model)
|
||||
except: # pragma: no cover
|
||||
pass
|
||||
models += [model._meta.model_name]
|
||||
except LookupError: # pragma: no cover
|
||||
# if an error occurs the app was never loaded right -> so nothing to do anymore
|
||||
|
@ -15,8 +15,8 @@ from django.utils import timezone
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
from common.serializers import SettingsSerializer
|
||||
from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
|
||||
from common.serializers import GenericReferencedSettingSerializer
|
||||
|
||||
|
||||
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||
@ -128,22 +128,25 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
||||
return ret
|
||||
|
||||
|
||||
class PluginSettingSerializer(SettingsSerializer):
|
||||
class PluginSettingSerializer(GenericReferencedSettingSerializer):
|
||||
"""
|
||||
Serializer for the PluginSetting model
|
||||
"""
|
||||
|
||||
MODEL = PluginSetting
|
||||
EXTRA_FIELDS = [
|
||||
'plugin',
|
||||
]
|
||||
|
||||
plugin = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PluginSetting
|
||||
fields = [
|
||||
'pk',
|
||||
'key',
|
||||
'value',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'choices',
|
||||
'plugin',
|
||||
]
|
||||
|
||||
class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
|
||||
"""
|
||||
Serializer for the PluginSetting model
|
||||
"""
|
||||
|
||||
MODEL = NotificationUserSetting
|
||||
EXTRA_FIELDS = ['method', ]
|
||||
|
||||
method = serializers.CharField(read_only=True)
|
||||
|
@ -8,7 +8,7 @@ from django.urls import reverse
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from plugin import registry
|
||||
|
||||
from common.notifications import storage
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@ -73,3 +73,11 @@ def plugin_errors(*args, **kwargs):
|
||||
All plugin errors in the current session
|
||||
"""
|
||||
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))
|
||||
|
@ -5,6 +5,8 @@
|
||||
{% setting_object key plugin=plugin as setting %}
|
||||
{% elif user_setting %}
|
||||
{% setting_object key user=request.user as setting %}
|
||||
{% elif notification_setting %}
|
||||
{% setting_object key method=method user=request.user as setting %}
|
||||
{% else %}
|
||||
{% setting_object key as setting %}
|
||||
{% endif %}
|
||||
@ -22,7 +24,7 @@
|
||||
<td>
|
||||
{% if setting.is_bool %}
|
||||
<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>
|
||||
{% else %}
|
||||
<div id='setting-{{ setting.pk }}'>
|
||||
|
@ -70,6 +70,7 @@ $('table').find('.boolean-setting').change(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
var plugin = $(this).attr('plugin');
|
||||
var user = $(this).attr('user');
|
||||
var notification = $(this).attr('notification');
|
||||
|
||||
var checked = this.checked;
|
||||
|
||||
@ -80,6 +81,8 @@ $('table').find('.boolean-setting').change(function() {
|
||||
url = `/api/plugin/settings/${pk}/`;
|
||||
} else if (user) {
|
||||
url = `/api/settings/user/${pk}/`;
|
||||
} else if (notification) {
|
||||
url = `/api/settings/notification/${pk}/`;
|
||||
}
|
||||
|
||||
inventreePut(
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load plugin_extras %}
|
||||
|
||||
{% block label %}user-notifications{% endblock label %}
|
||||
|
||||
@ -12,7 +13,10 @@
|
||||
<div class='row'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -78,6 +78,7 @@ class RuleSet(models.Model):
|
||||
'otp_static_staticdevice',
|
||||
'plugin_pluginconfig',
|
||||
'plugin_pluginsetting',
|
||||
'plugin_notificationusersetting',
|
||||
],
|
||||
'part_category': [
|
||||
'part_partcategory',
|
||||
|
Loading…
Reference in New Issue
Block a user