diff --git a/README.rst b/README.rst index f7f43b7c..dc6139e8 100644 --- a/README.rst +++ b/README.rst @@ -527,11 +527,61 @@ value you want but it needs to be unique. For more details read `preventing dupl Notification Types ------------------ -**OpenWISP Notifications** simplifies configuring individual notification by -using notification types. You can think of a notification type as a template +**OpenWISP Notifications** allows defining notification types for +recurring events. Think of a notification type as a template for notifications. -These properties can be configured for each notification type: +``generic_message`` +~~~~~~~~~~~~~~~~~~~ + +.. figure:: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/1.1/generic_message.png + :target: https://raw.githubusercontent.com/openwisp/openwisp-notifications/docs/docs/images/1.1/generic_message.png + :align: center + +This module includes a notification type called ``generic_message``. + +This notification type is designed to deliver custom messages in the +user interface for infrequent events or errors that occur during +background operations and cannot be communicated easily to the user +in other ways. + +These messages may require longer explanations and are therefore +displayed in a dialog overlay, as shown in the screenshot above. +This notification type does not send emails. + +The following code example demonstrates how to send a notification +of this type: + +.. code-block:: python + + from openwisp_notifications.signals import notify + notify.send( + type='generic_message', + level='error', + message='An unexpected error happened!', + sender=User.objects.first(), + target=User.objects.last(), + description="""Lorem Ipsum is simply dummy text + of the printing and typesetting industry. + + ### Heading 3 + + Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, + when an unknown printer took a galley of type and scrambled it to make a + type specimen book. + + It has survived not only **five centuries**, but also the leap into + electronic typesetting, remaining essentially unchanged. + + It was popularised in the 1960s with the release of Letraset sheets + containing Lorem Ipsum passages, and more recently with desktop publishing + software like Aldus PageMaker including versions of *Lorem Ipsum*.""" + ) + +Properties of Notification Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following properties can be configured for each notification type: +------------------------+----------------------------------------------------------------+ | **Property** | **Description** | @@ -569,8 +619,15 @@ These properties can be configured for each notification type: +------------------------+----------------------------------------------------------------+ -**Note**: A notification type configuration should contain atleast one of ``message`` or ``message_template`` -settings. If both of them are present, ``message`` is given preference over ``message_template``. +**Note**: It is recommended that a notification type configuration +for recurring events contains either the ``message`` or +``message_template`` properties. If both are present, +``message`` is given preference over ``message_template``. + +If you don't plan on using ``message`` or ``message_template``, +it may be better to use the existing ``generic_message`` type. +However, it's advised to do so only if the event being notified +is infrequent. **Note**: The callable for ``actor_link``, ``action_object_link`` and ``target_link`` should have the following signature: diff --git a/openwisp_notifications/api/serializers.py b/openwisp_notifications/api/serializers.py index 4303d4bf..d4dbd41d 100644 --- a/openwisp_notifications/api/serializers.py +++ b/openwisp_notifications/api/serializers.py @@ -55,10 +55,13 @@ def data(self): class NotificationListSerializer(NotificationSerializer): + description = serializers.CharField(source='rendered_description') + class Meta(NotificationSerializer.Meta): fields = [ 'id', 'message', + 'description', 'unread', 'target_url', 'email_subject', diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index bbcf3793..56bb0f76 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -1,4 +1,5 @@ import logging +from contextlib import contextmanager from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey @@ -29,6 +30,35 @@ logger = logging.getLogger(__name__) +@contextmanager +def notification_render_attributes(obj, **attrs): + """ + This context manager sets temporary attributes on + the notification object to allowing rendering of + notification. + + It can only be used to set aliases of the existing attributes. + By default, it will set the following aliases: + - actor_link -> actor_url + - action_link -> action_url + - target_link -> target_url + """ + defaults = { + 'actor_link': 'actor_url', + 'action_link': 'action_url', + 'target_link': 'target_url', + } + defaults.update(attrs) + + for target_attr, source_attr in defaults.items(): + setattr(obj, target_attr, getattr(obj, source_attr)) + + yield obj + + for attr in defaults.keys(): + delattr(obj, attr) + + class AbstractNotification(UUIDModel, BaseNotification): CACHE_KEY_PREFIX = 'ow-notifications-' type = models.CharField(max_length=30, null=True, choices=NOTIFICATION_CHOICES) @@ -103,40 +133,44 @@ def target_url(self): @cached_property def message(self): - return self.get_message() + with notification_render_attributes(self): + return self.get_message() + + @cached_property + def rendered_description(self): + if not self.description: + return + with notification_render_attributes(self): + data = self.data or {} + desc = self.description.format(notification=self, **data) + return mark_safe(markdown(desc)) @property def email_message(self): - return self.get_message(email_message=True) + with notification_render_attributes(self, target_link='redirect_view_url'): + return self.get_message() - def get_message(self, email_message=False): - if self.type: - # setting links in notification object for message rendering - self.actor_link = self.actor_url - self.action_link = self.action_url - self.target_link = ( - self.target_url if not email_message else self.redirect_view_url - ) - try: - config = get_notification_configuration(self.type) - data = self.data or {} - if 'message' in config: - md_text = config['message'].format(notification=self, **data) - else: - md_text = render_to_string( - config['message_template'], context=dict(notification=self) - ).strip() - except (AttributeError, KeyError, NotificationRenderException) as exception: - self._invalid_notification( - self.pk, - exception, - 'Error encountered in rendering notification message', - ) - # clean up - self.actor_link = self.action_link = self.target_link = None - return mark_safe(markdown(md_text)) - else: + def get_message(self): + if not self.type: return self.description + try: + config = get_notification_configuration(self.type) + data = self.data or {} + if 'message' in data: + md_text = data['message'].format(notification=self, **data) + elif 'message' in config: + md_text = config['message'].format(notification=self, **data) + else: + md_text = render_to_string( + config['message_template'], context=dict(notification=self, **data) + ).strip() + except (AttributeError, KeyError, NotificationRenderException) as exception: + self._invalid_notification( + self.pk, + exception, + 'Error encountered in rendering notification message', + ) + return mark_safe(markdown(md_text)) @cached_property def email_subject(self): diff --git a/openwisp_notifications/handlers.py b/openwisp_notifications/handlers.py index c14efdf3..45cfc399 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -57,8 +57,8 @@ def notify_handler(**kwargs): except NotificationRenderException as error: logger.error(f'Error encountered while creating notification: {error}') return - level = notification_template.get( - 'level', kwargs.pop('level', Notification.LEVELS.info) + level = kwargs.pop( + 'level', notification_template.get('level', Notification.LEVELS.info) ) verb = notification_template.get('verb', kwargs.pop('verb', None)) user_app_name = User._meta.app_label @@ -195,7 +195,7 @@ def send_email_notification(sender, instance, created, **kwargs): # Do not send email if notification is malformed. return url = instance.data.get('url', '') if instance.data else None - description = instance.message + body_text = instance.email_message if url: target_url = url elif instance.target: @@ -203,14 +203,14 @@ def send_email_notification(sender, instance, created, **kwargs): else: target_url = None if target_url: - description += _('\n\nFor more information see %(target_url)s.') % { + body_text += _('\n\nFor more information see %(target_url)s.') % { 'target_url': target_url } send_email( - subject, - description, - instance.message, + subject=subject, + body_text=body_text, + body_html=instance.email_message, recipients=[instance.recipient.email], extra_context={ 'call_to_action_url': target_url, diff --git a/openwisp_notifications/static/openwisp-notifications/css/notifications.css b/openwisp_notifications/static/openwisp-notifications/css/notifications.css index 90b49de8..a8b27932 100644 --- a/openwisp_notifications/static/openwisp-notifications/css/notifications.css +++ b/openwisp_notifications/static/openwisp-notifications/css/notifications.css @@ -257,6 +257,7 @@ .ow-notification-level-text { padding: 0px 6px; text-transform: uppercase; + font-weight: bold; } .ow-notification-elem .icon, .ow-notification-toast .icon { @@ -300,6 +301,66 @@ } .ow-notification-elem:last-child .ow-notification-inner { border-bottom: none } +/* Generic notification dialog */ +.ow-overlay-notification { + background-color: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + transition: opacity 0.3s; +} +.ow-dialog-notification { + position: relative; + background-color: white; + padding: 20px; + padding-top: 20px; + border-radius: 10px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 500px; + text-align: left; +} +.ow-dialog-notification .ow-notification-date { + padding-right: 15px; +} +.ow-dialog-notification .button { + margin-right: 10px; +} +.ow-dialog-notification-level-wrapper { + display: flex; + justify-content: space-between; +} +.ow-dialog-notification .icon { + min-height: 15; + min-width: 15px; + background-repeat: no-repeat; +} +.ow-dialog-close-x { + cursor: pointer; + font-size: 1.75em; + position: absolute; + display: block; + font-weight: bold; + top: 3px; + right: 10px; +} +.ow-dialog-close-x:hover { + color: #df5d43; +} +.ow-message-title { + color: #333; + margin-bottom: 10px; +} +.ow-message-title a { + color: #df5d43; +} +.ow-message-title a:hover { + text-decoration: underline; +} +.ow-dialog-buttons { + line-height: 3em; +} + @media screen and (max-width: 600px) { .ow-notification-dropdown { width: 98%; diff --git a/openwisp_notifications/static/openwisp-notifications/js/notifications.js b/openwisp_notifications/static/openwisp-notifications/js/notifications.js index 16fa3312..811ed155 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/notifications.js +++ b/openwisp_notifications/static/openwisp-notifications/js/notifications.js @@ -2,6 +2,7 @@ const notificationReadStatus = new Map(); const userLanguage = navigator.language || navigator.userLanguage; const owWindowId = String(Date.now()); +let fetchedPages = []; if (typeof gettext === 'undefined') { var gettext = function(word){ return word; }; @@ -56,10 +57,15 @@ function initNotificationDropDown($) { $(document).click(function (e) { e.stopPropagation(); - // Check if the clicked area is dropDown / notification-btn or not if ( + // Check if the clicked area is dropDown $('.ow-notification-dropdown').has(e.target).length === 0 && - !$(e.target).is($('.ow-notifications')) + // Check notification-btn or not + !$(e.target).is($('.ow-notifications')) && + // Hide the notification dropdown when a click occurs outside of it + !$(e.target).is($('.ow-dialog-close')) && + // Do not hide if the user is interacting with the notification dialog + !$('.ow-overlay-notification').is(':visible') ) { $('.ow-notification-dropdown').addClass('ow-hide'); } @@ -69,7 +75,11 @@ function initNotificationDropDown($) { $(document).focusin(function(e){ // Hide notification widget if focus is shifted to an element outside it e.stopPropagation(); - if ($('.ow-notification-dropdown').has(e.target).length === 0){ + if ( + $('.ow-notification-dropdown').has(e.target).length === 0 && + // Do not hide if the user is interacting with the notification dialog + !$('.ow-overlay-notification').is(':visible') + ) { // Don't hide if focus changes to notification bell icon if (e.target != $('#openwisp_notifications').get(0)) { $('.ow-notification-dropdown').addClass('ow-hide'); @@ -77,22 +87,41 @@ function initNotificationDropDown($) { } }); - $('.ow-notification-dropdown').on('keyup', '*', function(e){ - e.stopPropagation(); + $('.ow-notification-dropdown').on('keyup', function(e){ + if (e.keyCode !== 27) { + return; + } // Hide notification widget on "Escape" key - if (e.keyCode == 27){ + if ($('.ow-overlay-notification').is(':visible')) { + $('.ow-overlay-notification').addClass('ow-hide'); + $('.ow-message-target-redirect').addClass('ow-hide'); + } else { $('.ow-notification-dropdown').addClass('ow-hide'); $('#openwisp_notifications').focus(); } }); } +// Used to convert absolute URLs in notification messages to relative paths +function convertMessageWithRelativeURL(htmlString) { + const parser = new DOMParser(), + doc = parser.parseFromString(htmlString, 'text/html'), + links = doc.querySelectorAll('a'); + links.forEach((link) => { + let url = link.getAttribute('href'); + if (url) { + url = new URL(url, window.location.href); + link.setAttribute('href', url.pathname); + } + }); + return doc.body.innerHTML; +} + function notificationWidget($) { let nextPageUrl = getAbsoluteUrl('/api/v1/notifications/notification/'), renderedPages = 2, busy = false, - fetchedPages = [], lastRenderedPage = 0; // 1 based indexing (0 -> no page rendered) @@ -197,7 +226,8 @@ function notificationWidget($) { function notificationListItem(elem) { let klass; const datetime = dateTimeStampToDateTimeLocaleString(new Date(elem.timestamp)), - target_url = new URL(elem.target_url); + // target_url can be null or '#', so we need to handle it without any errors + target_url = new URL(elem.target_url, window.location.href); if (!notificationReadStatus.has(elem.id)) { if (elem.unread) { @@ -208,32 +238,25 @@ function notificationWidget($) { } klass = notificationReadStatus.get(elem.id); - // Used to convert absolute URLs in notification messages to relative paths - function convertMessageWithRelativeURL(htmlString) { - const parser = new DOMParser(), - doc = parser.parseFromString(htmlString, 'text/html'), - links = doc.querySelectorAll('a'); - links.forEach((link) => { - let url = link.getAttribute('href'); - if (url) { - url = new URL(url); - link.setAttribute('href', url.pathname); - } - }); - return doc.body.innerHTML; + let message; + if (elem.description) { + // Remove hyperlinks from generic notifications to enforce the opening of the message dialog + message = elem.message.replace(/]*>([^<]*)<\/a>/g, '$1'); + } else { + message = convertMessageWithRelativeURL(elem.message); } return `
-
-
-
${elem.level}
-
-
${datetime}
+
+
+
${elem.level}
+
+
${datetime}
- ${convertMessageWithRelativeURL(elem.message)} + ${message}
`; } @@ -320,11 +343,16 @@ function notificationWidget($) { return; } let elem = $(this); - // If notification is unread then send read request - if (elem.hasClass('unread')) { - markNotificationRead(elem.get(0)); + notificationHandler($, elem); + }); + + // Close dialog on click, keypress or esc + $('.ow-dialog-close').on('click keypress', function (e) { + if (e.type === 'keypress' && e.which !== 13 && e.which !== 27) { + return; } - window.location = elem.data('location'); + $('.ow-overlay-notification').addClass('ow-hide'); + $('.ow-message-target-redirect').addClass('ow-hide'); }); // Handler for marking notification as read on mouseout event @@ -353,6 +381,45 @@ function markNotificationRead(elem) { ); } +function notificationHandler($, elem) { + var notification = fetchedPages.flat().find((notification) => + notification.id == elem.get(0).id.replace('ow-', '')), + targetUrl = elem.data('location'); + + // If notification is unread then send read request + if (!notification.description && elem.hasClass('unread')) { + markNotificationRead(elem.get(0)); + } + + if (notification.target_url && notification.target_url !== '#') { + targetUrl = new URL(notification.target_url).pathname; + $('.ow-message-target-redirect').removeClass('ow-hide'); + } + + // Notification with overlay dialog + if (notification.description) { + var datetime = dateTimeStampToDateTimeLocaleString(new Date(notification.timestamp)); + + $('.ow-dialog-notification-level-wrapper').html(` +
+
+
${notification.level}
+
+
${datetime}
+ `); + $('.ow-message-title').html(convertMessageWithRelativeURL(notification.message)); + $('.ow-message-description').html(notification.description); + $('.ow-overlay-notification').removeClass('ow-hide'); + + $(document).on('click', '.ow-message-target-redirect', function() { + window.location = targetUrl; + }); + // standard notification + } else { + window.location = targetUrl; + } +} + function initWebSockets($) { notificationSocket.addEventListener('message', function (e) { let data = JSON.parse(e.data); @@ -407,7 +474,7 @@ function initWebSockets($) { // Make toast message clickable $(document).on('click', '.ow-notification-toast', function () { markNotificationRead($(this).get(0)); - window.location = $(this).data('location'); + notificationHandler($, $(this)); }); $(document).on('click', '.ow-notification-toast .ow-notify-close.btn', function (event) { event.stopPropagation(); diff --git a/openwisp_notifications/templates/admin/base_site.html b/openwisp_notifications/templates/admin/base_site.html index 94150ec8..4861d9f1 100644 --- a/openwisp_notifications/templates/admin/base_site.html +++ b/openwisp_notifications/templates/admin/base_site.html @@ -38,6 +38,22 @@ {% endblock %} {% block footer %} +
+
+ × +
+

+
+
+ + +
+
+
{{ block.super }} {% if request.user.is_authenticated %} diff --git a/openwisp_notifications/tests/test_api.py b/openwisp_notifications/tests/test_api.py index 52b2a87f..5d5481aa 100644 --- a/openwisp_notifications/tests/test_api.py +++ b/openwisp_notifications/tests/test_api.py @@ -533,7 +533,7 @@ def test_notification_setting_list_api(self): self.assertEqual(len(response.data['results']), number_of_settings) with self.subTest('Test "page_size" query'): - page_size = 1 + page_size = 2 url = f'{url}?page_size={page_size}' response = self.client.get(url) self.assertEqual(response.status_code, 200) diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 04894ffd..e159a517 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -61,7 +61,6 @@ def setUp(self): self.notification_options = dict( sender=self.admin, description='Test Notification', - level='info', verb='Test Notification', email_subject='Test Email subject', url='https://localhost:8000/admin', @@ -313,7 +312,10 @@ def test_no_organization(self): def test_default_notification_type(self): self.notification_options.pop('verb') - self.notification_options.update({'type': 'default'}) + self.notification_options.pop('url') + self.notification_options.update( + {'type': 'default', 'target': self._get_org_user()} + ) self._create_notification() n = notification_queryset.first() self.assertEqual(n.level, 'info') @@ -322,6 +324,54 @@ def test_default_notification_type(self): 'Default notification with default verb and level info by', n.message ) self.assertEqual(n.email_subject, '[example.com] Default Notification Subject') + email = mail.outbox.pop() + html_email = email.alternatives[0][0] + self.assertEqual( + email.body, + ( + 'Default notification with default verb and' + ' level info by Tester Tester (test org)\n\n' + f'For more information see {n.redirect_view_url}.' + ), + ) + self.assertIn( + ( + '

Default notification with' + ' default verb and level info by' + f' ' + 'Tester Tester (test org)

' + ), + html_email, + ) + + def test_generic_notification_type(self): + self.notification_options.pop('verb') + self.notification_options.update( + { + 'message': '[{notification.actor}]({notification.actor_link})', + 'type': 'generic_message', + 'description': '[{notification.actor}]({notification.actor_link})', + } + ) + self._create_notification() + n = notification_queryset.first() + self.assertEqual(n.level, 'info') + self.assertEqual(n.verb, 'generic verb') + expected_output = ( + '

admin

' + ).format( + user_path=reverse('admin:openwisp_users_user_change', args=[self.admin.pk]) + ) + self.assertEqual(n.message, expected_output) + self.assertEqual(n.rendered_description, expected_output) + self.assertEqual(n.email_subject, '[example.com] Generic Notification Subject') + + def test_notification_level_kwarg_precedence(self): + # Create a notification with level kwarg set to 'warning' + self.notification_options.update({'level': 'warning'}) + self._create_notification() + n = notification_queryset.first() + self.assertEqual(n.level, 'warning') @mock_notification_types def test_misc_notification_type_validation(self): @@ -586,8 +636,9 @@ def test_related_objects_database_query(self): {'action_object': operator, 'target': operator} ) self._create_notification() - with self.assertNumQueries(2): - # 2 queries since admin is already cached + with self.assertNumQueries(1): + # 1 query since all related objects are cached + # when rendering the notification n = notification_queryset.first() self.assertEqual(n.actor, self.admin) self.assertEqual(n.action_object, operator) diff --git a/openwisp_notifications/tests/test_widget.py b/openwisp_notifications/tests/test_widget.py index 0fc011ca..48bebeca 100644 --- a/openwisp_notifications/tests/test_widget.py +++ b/openwisp_notifications/tests/test_widget.py @@ -3,6 +3,7 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait +from openwisp_notifications.signals import notify from openwisp_notifications.swapper import load_model from openwisp_notifications.utils import _get_object_link from openwisp_users.tests.utils import TestOrganizationMixin @@ -22,27 +23,23 @@ def setUp(self): self.admin = self._create_admin( username=self.admin_username, password=self.admin_password ) - - def test_notification_relative_link(self): - self.login() - operator = super()._create_operator() - data = dict( - email_subject='Test Email subject', - url='http://localhost:8000/admin/', - ) - notification = Notification.objects.create( - actor=self.admin, + self.operator = super()._get_operator() + self.notification_options = dict( + sender=self.admin, recipient=self.admin, - description='Test Notification Description', verb='Test Notification', - action_object=operator, - target=operator, - data=data, - ) - self.web_driver.implicitly_wait(10) - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.ID, 'openwisp_notifications')) + email_subject='Test Email subject', + action_object=self.operator, + target=self.operator, + type='default', ) + + def _create_notification(self): + return notify.send(**self.notification_options) + + def test_notification_relative_link(self): + self.login() + notification = self._create_notification().pop()[1][0] self.web_driver.find_element(By.ID, 'openwisp_notifications').click() WebDriverWait(self.web_driver, 10).until( EC.visibility_of_element_located((By.CLASS_NAME, 'ow-notification-elem')) @@ -54,3 +51,45 @@ def test_notification_relative_link(self): self.assertEqual( data_location_value, _get_object_link(notification, 'target', False) ) + + def test_notification_dialog(self): + self.login() + self.notification_options.update( + {'message': 'Test Message', 'description': 'Test Description'} + ) + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) + ) + self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) + ) + dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') + self.assertEqual( + dialog.find_element(By.CLASS_NAME, 'ow-message-title').text, 'Test Message' + ) + self.assertEqual( + dialog.find_element(By.CLASS_NAME, 'ow-message-description').text, + 'Test Description', + ) + + def test_notification_dialog_open_button_visibility(self): + self.login() + self.notification_options.pop('target') + self.notification_options.update( + {'message': 'Test Message', 'description': 'Test Description'} + ) + notification = self._create_notification().pop()[1][0] + self.web_driver.find_element(By.ID, 'openwisp_notifications').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.ID, f'ow-{notification.id}')) + ) + self.web_driver.find_element(By.ID, f'ow-{notification.id}').click() + WebDriverWait(self.web_driver, 10).until( + EC.visibility_of_element_located((By.CLASS_NAME, 'ow-dialog-notification')) + ) + dialog = self.web_driver.find_element(By.CLASS_NAME, 'ow-dialog-notification') + # This confirms the button is hidden + dialog.find_element(By.CSS_SELECTOR, '.ow-message-target-redirect.ow-hide') diff --git a/openwisp_notifications/types.py b/openwisp_notifications/types.py index 88a5f436..9e7e84a9 100644 --- a/openwisp_notifications/types.py +++ b/openwisp_notifications/types.py @@ -11,15 +11,31 @@ 'email_subject': '[{site.name}] Default Notification Subject', 'message': ( 'Default notification with {notification.verb} and level {notification.level}' - ' by [{notification.actor}]({notification.actor_link})' + ' by [{notification.target}]({notification.target_link})' ), 'message_template': 'openwisp_notifications/default_message.md', 'email_notification': True, 'web_notification': True, }, + 'generic_message': { + 'level': 'info', + 'verb': 'generic verb', + 'verbose_name': 'Generic Type', + 'email_subject': '[{site.name}] Generic Notification Subject', + 'message': ( + 'Generic notification with {notification.verb} and level {notification.level}' + ' by [{notification.actor}]({notification.actor_link})' + ), + 'description': '{notification.description}', + 'email_notification': False, + 'web_notification': True, + }, } -NOTIFICATION_CHOICES = [('default', 'Default Type')] +NOTIFICATION_CHOICES = [ + ('default', 'Default Type'), + ('generic_message', 'Generic Message Type'), +] NOTIFICATION_ASSOCIATED_MODELS = set()