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
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()

View File

@ -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

View File

@ -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):

View File

@ -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)

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):
"""
Serializer for the InvenTreeUserSetting model

View File

@ -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')

View File

@ -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):
"""

View File

@ -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)

View File

@ -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)

View File

@ -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)

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 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',
]

View File

@ -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}'

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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 }}'>

View File

@ -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(

View File

@ -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>

View File

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