diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 6b2f2b1610..7787bbfb0c 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -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() diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 2f7f3e2ca8..f8aaa2ded9 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -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\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 diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 9dac93d28e..1434bba95e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -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): diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index fe737fc919..ef4de4fc61 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -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) diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 86d45cd881..9d637c3e39 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -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 diff --git a/InvenTree/common/test_notifications.py b/InvenTree/common/test_notifications.py index 3c0009fb51..719d9291de 100644 --- a/InvenTree/common/test_notifications.py +++ b/InvenTree/common/test_notifications.py @@ -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') diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index e2966fb8d5..dee776f7d9 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -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): """ diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index c0ab91a41f..c97090e4f6 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -941,7 +941,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer): shipment.complete_shipment(user, tracking_number=tracking_number) -class SOShipmentAllocationItemSerializer(serializers.Serializer): +class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer): """ A serializer for allocating a single stock-item against a SalesOrder shipment """ @@ -1233,7 +1233,7 @@ class SalesOrderShipmentAllocationSerializer(serializers.Serializer): 'shipment', ] - items = SOShipmentAllocationItemSerializer(many=True) + items = SalesOrderShipmentAllocationItemSerializer(many=True) shipment = serializers.PrimaryKeyRelatedField( queryset=order.models.SalesOrderShipment.objects.all(), diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py index f636d91fb9..a0445e3dd6 100644 --- a/InvenTree/order/test_views.py +++ b/InvenTree/order/test_views.py @@ -53,7 +53,7 @@ class OrderListTest(OrderViewTestCase): self.assertEqual(response.status_code, 200) -class POTests(OrderViewTestCase): +class PurchaseOrderTests(OrderViewTestCase): """ Tests for PurchaseOrder views """ def test_detail_view(self): diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 3eba8368af..c30e604e68 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -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) @@ -540,7 +545,7 @@ class I18nStaticNode(StaticNode): custom StaticNode replaces a variable named *lng* in the path with the current language """ - def render(self, context): + def render(self, context): # pragma: no cover self.original = getattr(self, 'original', None) @@ -548,7 +553,8 @@ class I18nStaticNode(StaticNode): # Store the original (un-rendered) path template, as it gets overwritten below self.original = self.path.var - self.path.var = self.original.format(lng=context.request.LANGUAGE_CODE) + if hasattr(context, 'request'): + self.path.var = self.original.format(lng=context.request.LANGUAGE_CODE) ret = super().render(context) @@ -563,7 +569,7 @@ if settings.DEBUG: """ simple tag to enable {% url %} functionality instead of {% static %} """ return reverse(url_name) -else: +else: # pragma: no cover @register.tag('i18n_static') def do_i18n_static(parser, token): diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 811acebc69..f3e1d4490d 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -18,17 +18,52 @@ from .templatetags import inventree_extras import part.settings +from InvenTree import version from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage +from common.notifications import storage, UIMessageNotification class TemplateTagTest(TestCase): """ Tests for the custom template tag code """ + def setUp(self): + # Create a user for auth + user = get_user_model() + self.user = user.objects.create_user('testuser', 'test@testing.com', 'password') + self.client.login(username='testuser', password='password') + + def test_define(self): + self.assertEqual(int(inventree_extras.define(3)), 3) + + def test_str2bool(self): + self.assertEqual(int(inventree_extras.str2bool('true')), True) + self.assertEqual(int(inventree_extras.str2bool('yes')), True) + self.assertEqual(int(inventree_extras.str2bool('none')), False) + self.assertEqual(int(inventree_extras.str2bool('off')), False) + + def test_inrange(self): + self.assertEqual(inventree_extras.inrange(3), range(3)) + def test_multiply(self): self.assertEqual(int(inventree_extras.multiply(3, 5)), 15) - def test_version(self): - self.assertEqual(type(inventree_extras.inventree_version()), str) + def test_add(self): + self.assertEqual(int(inventree_extras.add(3, 5)), 8) + + def test_plugins_enabled(self): + self.assertEqual(inventree_extras.plugins_enabled(), True) + + def test_inventree_instance_name(self): + self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server') + + def test_inventree_base_url(self): + self.assertEqual(inventree_extras.inventree_base_url(), '') + + def test_inventree_is_release(self): + self.assertEqual(inventree_extras.inventree_is_release(), not version.isInvenTreeDevelopmentVersion()) + + def test_inventree_docs_version(self): + self.assertEqual(inventree_extras.inventree_docs_version(), version.inventreeDocsVersion()) def test_hash(self): result_hash = inventree_extras.inventree_commit_hash() @@ -44,6 +79,24 @@ class TemplateTagTest(TestCase): def test_docs(self): self.assertIn('inventree.readthedocs.io', inventree_extras.inventree_docs_url()) + def test_keyvalue(self): + self.assertEqual(inventree_extras.keyvalue({'a': 'a'}, 'a'), 'a') + + def test_mail_configured(self): + self.assertEqual(inventree_extras.mail_configured(), False) + + def test_user_settings(self): + result = inventree_extras.user_settings(self.user) + self.assertEqual(len(result), 36) + + def test_global_settings(self): + result = inventree_extras.global_settings() + self.assertEqual(len(result), 61) + + def test_visible_global_settings(self): + result = inventree_extras.visible_global_settings() + self.assertEqual(len(result), 60) + class PartTest(TestCase): """ Tests for the Part model """ @@ -513,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 @@ -536,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) diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index ba5148e6e3..256b5ce0da 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -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) diff --git a/InvenTree/plugin/builtin/integration/core_notifications.py b/InvenTree/plugin/builtin/integration/core_notifications.py new file mode 100644 index 0000000000..a26c69e877 --- /dev/null +++ b/InvenTree/plugin/builtin/integration/core_notifications.py @@ -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 diff --git a/InvenTree/plugin/builtin/integration/test_core_notifications.py b/InvenTree/plugin/builtin/integration/test_core_notifications.py new file mode 100644 index 0000000000..02c91784e5 --- /dev/null +++ b/InvenTree/plugin/builtin/integration/test_core_notifications.py @@ -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) diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py index aaba9fe060..538bd2358b 100644 --- a/InvenTree/plugin/loader.py +++ b/InvenTree/plugin/loader.py @@ -15,5 +15,5 @@ class PluginTemplateLoader(FilesystemLoader): for plugin in registry.plugins.values(): new_path = Path(plugin.path) / dirname if Path(new_path).is_dir(): - template_dirs.append(new_path) + template_dirs.append(new_path) # pragma: no cover return tuple(template_dirs) diff --git a/InvenTree/plugin/migrations/0005_notificationusersetting.py b/InvenTree/plugin/migrations/0005_notificationusersetting.py new file mode 100644 index 0000000000..4ea1959f90 --- /dev/null +++ b/InvenTree/plugin/migrations/0005_notificationusersetting.py @@ -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')}, + }, + ), + ] diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 86e5e92f37..900289ae37 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -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', ] diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 44eeafd012..0624693abc 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -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}' diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index b83297dcfd..c6af7f20d8 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -80,7 +80,7 @@ class InvenTreePluginBase(): if cfg: return cfg.active else: - return False + return False # pragma: no cover # TODO @matmair remove after InvenTree 0.7.0 release diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 240bd3446b..1249d95aa3 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -503,7 +503,7 @@ class PluginsRegistry: try: # for local path plugins plugin_path = '.'.join(pathlib.Path(plugin.path).relative_to(settings.BASE_DIR).parts) - except ValueError: + except ValueError: # pragma: no cover # plugin is shipped as package plugin_path = plugin.PLUGIN_NAME return plugin_path @@ -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 diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index 7a76c48067..276604b390 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -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) diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index 9e83cf96aa..a30f7ec2e4 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -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)) diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index f99128999c..a40990b527 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -227,7 +227,7 @@ class BOMReportTest(ReportTest): print_url = 'api-bom-report-print' -class POReportTest(ReportTest): +class PurchaseOrderReportTest(ReportTest): model = report_models.PurchaseOrderReport @@ -236,7 +236,7 @@ class POReportTest(ReportTest): print_url = 'api-po-report-print' -class SOReportTest(ReportTest): +class SalesOrderReportTest(ReportTest): model = report_models.SalesOrderReport diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 9ef6008292..95865700fe 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -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 @@ {% if setting.is_bool %}
- +
{% else %}
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 0039e8ee1a..b35ec0107a 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -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( diff --git a/InvenTree/templates/InvenTree/settings/user_notifications.html b/InvenTree/templates/InvenTree/settings/user_notifications.html index 4e9889ca69..6d67d34851 100644 --- a/InvenTree/templates/InvenTree/settings/user_notifications.html +++ b/InvenTree/templates/InvenTree/settings/user_notifications.html @@ -2,6 +2,7 @@ {% load i18n %} {% load inventree_extras %} +{% load plugin_extras %} {% block label %}user-notifications{% endblock label %} @@ -12,7 +13,10 @@
- {% 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 %}
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 8f42268224..7c5c406687 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -78,6 +78,7 @@ class RuleSet(models.Model): 'otp_static_staticdevice', 'plugin_pluginconfig', 'plugin_pluginsetting', + 'plugin_notificationusersetting', ], 'part_category': [ 'part_partcategory',