diff --git a/README.rst b/README.rst index dc6139e8..70d7b626 100644 --- a/README.rst +++ b/README.rst @@ -1013,6 +1013,30 @@ The default configuration is as follows: 'max_allowed_backoff': 15, } +``OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++---------+-----------------------------------+ +| type | ``int`` | ++---------+-----------------------------------+ +| default | ``1800`` `(30 mins, in seconds)` | ++---------+-----------------------------------+ + +This setting defines the interval at which the email notifications are sent in batches to users within the specified interval. + +If you want to send email notifications immediately, then set it to ``0``. + +``OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++---------+-----------------------------------+ +| type | ``int`` | ++---------+-----------------------------------+ +| default | ``15`` | ++---------+-----------------------------------+ + +This setting defines the number of email notifications to be displayed in a batched email. + Exceptions ---------- diff --git a/openwisp_notifications/base/models.py b/openwisp_notifications/base/models.py index 56bb0f76..75b5ea4b 100644 --- a/openwisp_notifications/base/models.py +++ b/openwisp_notifications/base/models.py @@ -139,7 +139,7 @@ def message(self): @cached_property def rendered_description(self): if not self.description: - return + return '' with notification_render_attributes(self): data = self.data or {} desc = self.description.format(notification=self, **data) diff --git a/openwisp_notifications/handlers.py b/openwisp_notifications/handlers.py index 6949ab28..732428b8 100644 --- a/openwisp_notifications/handlers.py +++ b/openwisp_notifications/handlers.py @@ -10,7 +10,6 @@ from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone -from django.utils.translation import gettext as _ from openwisp_notifications import settings as app_settings from openwisp_notifications import tasks @@ -20,12 +19,13 @@ NOTIFICATION_ASSOCIATED_MODELS, get_notification_configuration, ) +from openwisp_notifications.utils import send_notification_email from openwisp_notifications.websockets import handlers as ws_handlers -from openwisp_utils.admin_theme.email import send_email logger = logging.getLogger(__name__) EXTRA_DATA = app_settings.get_config()['USE_JSONFIELD'] +EMAIL_BATCH_INTERVAL = app_settings.EMAIL_BATCH_INTERVAL User = get_user_model() @@ -192,35 +192,46 @@ def send_email_notification(sender, instance, created, **kwargs): if not (email_preference and instance.recipient.email and email_verified): return - try: - subject = instance.email_subject - except NotificationRenderException: - # Do not send email if notification is malformed. - return - url = instance.data.get('url', '') if instance.data else None - body_text = instance.email_message - if url: - target_url = url - elif instance.target: - target_url = instance.redirect_view_url - else: - target_url = None - if target_url: - body_text += _('\n\nFor more information see %(target_url)s.') % { - 'target_url': target_url - } - - send_email( - subject=subject, - body_text=body_text, - body_html=instance.email_message, - recipients=[instance.recipient.email], - extra_context={ - 'call_to_action_url': target_url, - 'call_to_action_text': _('Find out more'), + recipient_id = instance.recipient.id + cache_key = f'email_batch_{recipient_id}' + + cache_data = cache.get( + cache_key, + { + 'last_email_sent_time': None, + 'batch_scheduled': False, + 'pks': [], + 'start_time': None, + 'email_id': instance.recipient.email, }, ) + if cache_data['last_email_sent_time'] and EMAIL_BATCH_INTERVAL > 0: + # Case 1: Batch email sending logic + if not cache_data['batch_scheduled']: + # Schedule batch email notification task if not already scheduled + tasks.send_batched_email_notifications.apply_async( + (instance.recipient.id,), countdown=EMAIL_BATCH_INTERVAL + ) + # Mark batch as scheduled to prevent duplicate scheduling + cache_data['batch_scheduled'] = True + cache_data['pks'] = [instance.id] + cache_data['start_time'] = timezone.now() + cache.set(cache_key, cache_data) + else: + # Add current instance ID to the list of IDs for batch + cache_data['pks'].append(instance.id) + cache.set(cache_key, cache_data) + return + + # Case 2: Single email sending logic + # Update the last email sent time and cache the data + if EMAIL_BATCH_INTERVAL > 0: + cache_data['last_email_sent_time'] = timezone.now() + cache.set(cache_key, cache_data, timeout=EMAIL_BATCH_INTERVAL) + + send_notification_email(instance) + # flag as emailed instance.emailed = True # bulk_update is used to prevent emitting post_save signal diff --git a/openwisp_notifications/settings.py b/openwisp_notifications/settings.py index ea57bddb..9498c4d9 100644 --- a/openwisp_notifications/settings.py +++ b/openwisp_notifications/settings.py @@ -37,6 +37,14 @@ 'openwisp-notifications/audio/notification_bell.mp3', ) +EMAIL_BATCH_INTERVAL = getattr( + settings, 'OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL', 30 * 60 # 30 minutes +) + +EMAIL_BATCH_DISPLAY_LIMIT = getattr( + settings, 'OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT', 15 +) + # Remove the leading "/static/" here as it will # conflict with the "static()" call in context_processors.py. # This is done for backward compatibility. diff --git a/openwisp_notifications/static/openwisp-notifications/js/notifications.js b/openwisp_notifications/static/openwisp-notifications/js/notifications.js index 811ed155..fea7c162 100644 --- a/openwisp_notifications/static/openwisp-notifications/js/notifications.js +++ b/openwisp_notifications/static/openwisp-notifications/js/notifications.js @@ -100,6 +100,12 @@ function initNotificationDropDown($) { $('#openwisp_notifications').focus(); } }); + + // Show notification widget if URL has open_notification_widget query parameter + if (new URLSearchParams(window.location.search).get('open_notification_widget') === 'true') { + $('.ow-notification-dropdown').removeClass('ow-hide'); + $('.ow-notification-wrapper').trigger('refreshNotificationWidget'); + } } // Used to convert absolute URLs in notification messages to relative paths diff --git a/openwisp_notifications/tasks.py b/openwisp_notifications/tasks.py index 9869d46a..6c9335b7 100644 --- a/openwisp_notifications/tasks.py +++ b/openwisp_notifications/tasks.py @@ -3,14 +3,23 @@ from celery import shared_task from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.core.cache import cache from django.db.models import Q from django.db.utils import OperationalError +from django.template.loader import render_to_string from django.utils import timezone +from django.utils.translation import gettext as _ +from openwisp_notifications import settings as app_settings from openwisp_notifications import types from openwisp_notifications.swapper import load_model, swapper_load_model +from openwisp_notifications.utils import send_notification_email +from openwisp_utils.admin_theme.email import send_email from openwisp_utils.tasks import OpenwispCeleryTask +EMAIL_BATCH_INTERVAL = app_settings.EMAIL_BATCH_INTERVAL + User = get_user_model() Notification = load_model('Notification') @@ -202,3 +211,79 @@ def delete_ignore_object_notification(instance_id): Deletes IgnoreObjectNotification object post it's expiration. """ IgnoreObjectNotification.objects.filter(id=instance_id).delete() + + +@shared_task(base=OpenwispCeleryTask) +def send_batched_email_notifications(instance_id): + """ + Sends a summary of notifications to the specified email address. + """ + if not instance_id: + return + + cache_key = f'email_batch_{instance_id}' + cache_data = cache.get(cache_key, {'pks': []}) + + if not cache_data['pks']: + return + + display_limit = app_settings.EMAIL_BATCH_DISPLAY_LIMIT + unsent_notifications = Notification.objects.filter( + id__in=cache_data['pks'] + ).order_by('-timestamp') + notifications_count = unsent_notifications.count() + current_site = Site.objects.get_current() + email_id = cache_data.get('email_id') + + # Send individual email if there is only one notification + if notifications_count == 1: + notification = unsent_notifications.first() + send_notification_email(notification) + else: + # Show the amount of notifications according to configured display limit + show_notification_description = notifications_count <= display_limit + for notification in unsent_notifications: + url = notification.data.get('url', '') if notification.data else None + if url: + notification.url = url + elif notification.target: + notification.url = notification.redirect_view_url + else: + notification.url = None + + starting_time = ( + cache_data.get('start_time') + .strftime('%B %-d, %Y, %-I:%M %p') + .lower() + .replace('am', 'a.m.') + .replace('pm', 'p.m.') + ) + ' UTC' + + context = { + 'notifications': unsent_notifications, + 'notifications_count': notifications_count, + 'show_notification_description': show_notification_description, + 'site_name': current_site.name, + 'start_time': starting_time, + } + html_content = render_to_string('emails/batch_email.html', context) + plain_text_content = render_to_string('emails/batch_email.txt', context) + + extra_context = {} + if notifications_count > display_limit: + extra_context = { + 'call_to_action_url': f"https://{current_site.domain}/admin?open_notification_widget=true", + 'call_to_action_text': _('View all Notifications'), + } + + send_email( + subject=f'[{current_site.name}] {notifications_count} new notifications since {starting_time}', + body_text=plain_text_content, + body_html=html_content, + recipients=[email_id], + extra_context=extra_context, + ) + + unsent_notifications.update(emailed=True) + Notification.objects.bulk_update(unsent_notifications, ['emailed']) + cache.delete(cache_key) diff --git a/openwisp_notifications/templates/emails/batch_email.html b/openwisp_notifications/templates/emails/batch_email.html new file mode 100644 index 00000000..46bdc630 --- /dev/null +++ b/openwisp_notifications/templates/emails/batch_email.html @@ -0,0 +1,107 @@ +{% block styles %} + +{% endblock styles %} + +{% block mail_body %} +
+ {% for notification in notifications %} +
+

+ {{ notification.level|upper }} + + {% if notification.url %} + {{ notification.message }} + {% else %} + {{ notification.message }} + {% endif %} + +

+

{{ notification.timestamp|date:"F j, Y, g:i a" }}

+ {% if show_notification_description %} +

{{ notification.rendered_description|safe }}

+ {% endif %} +
+ {% endfor %} +
+{% endblock mail_body %} diff --git a/openwisp_notifications/templates/emails/batch_email.txt b/openwisp_notifications/templates/emails/batch_email.txt new file mode 100644 index 00000000..20338cbb --- /dev/null +++ b/openwisp_notifications/templates/emails/batch_email.txt @@ -0,0 +1,16 @@ +{% load i18n %} + +[{{ site_name }}] {{ notifications_count }} {% translate "new notifications since" %} {{ start_time }} + +{% for notification in notifications %} +- {{ notification.message }} + {% translate "URL" %}: {{ notification.url }} + {% translate "Timestamp" %}: {{ notification.timestamp|date:"F j, Y, g:i a" }} + {% if show_notification_description %} + {% translate "Description" %}: {{ notification.rendered_description }} + {% endif %} +{% endfor %} + +{% if not show_notification_description %} +{% translate "View all Notifications" %}: {{ call_to_action_url }} +{% endif %} diff --git a/openwisp_notifications/tests/test_notifications.py b/openwisp_notifications/tests/test_notifications.py index 761d771f..1c4d4e45 100644 --- a/openwisp_notifications/tests/test_notifications.py +++ b/openwisp_notifications/tests/test_notifications.py @@ -11,7 +11,7 @@ from django.core.exceptions import ImproperlyConfigured from django.db.models.signals import post_migrate, post_save from django.template import TemplateDoesNotExist -from django.test import TransactionTestCase +from django.test import TransactionTestCase, override_settings from django.urls import reverse from django.utils import timezone from django.utils.timesince import timesince @@ -338,7 +338,7 @@ def test_default_notification_type(self): ( '

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

' ), html_email, @@ -944,6 +944,59 @@ def test_notification_for_unverified_email(self): # we don't send emails to unverified email addresses self.assertEqual(len(mail.outbox), 0) + @patch('openwisp_notifications.tasks.send_batched_email_notifications.apply_async') + def test_batch_email_notification_with_descriptions(self, mock_send_email): + for _ in range(5): + notify.send(recipient=self.admin, **self.notification_options) + + # Check if only one mail is sent initially + self.assertEqual(len(mail.outbox), 1) + + # Call the task + tasks.send_batched_email_notifications(self.admin.id) + + # Check if the rest of the notifications are sent in a batch + self.assertEqual(len(mail.outbox), 2) + self.assertIn('4 new notifications since', mail.outbox[1].subject) + self.assertNotIn('View all Notifications', mail.outbox[1].body) + self.assertIn('Test Notification', mail.outbox[1].body) + + @patch('openwisp_notifications.tasks.send_batched_email_notifications.apply_async') + def test_batch_email_notification_with_call_to_action(self, mock_send_email): + self.notification_options.update( + { + 'message': 'Notification title', + 'type': 'default', + } + ) + for _ in range(21): + notify.send(recipient=self.admin, **self.notification_options) + + # Check if only one mail is sent initially + self.assertEqual(len(mail.outbox), 1) + + # Call the task + tasks.send_batched_email_notifications(self.admin.id) + + # Check if the rest of the notifications are sent in a batch + self.assertEqual(len(mail.outbox), 2) + self.assertIn('20 new notifications since', mail.outbox[1].subject) + self.assertIn('View all Notifications', mail.outbox[1].body) + self.assertNotIn('Test Notification', mail.outbox[1].body) + + @override_settings(EMAIL_BATCH_INTERVAL=0) + def test_without_batch_email_notification(self): + self.notification_options.update( + { + 'message': 'Notification title', + 'type': 'default', + } + ) + for _ in range(3): + notify.send(recipient=self.admin, **self.notification_options) + + self.assertEqual(len(mail.outbox), 3) + def test_that_the_notification_is_only_sent_once_to_the_user(self): first_org = self._create_org() first_org.organization_id = first_org.id diff --git a/openwisp_notifications/utils.py b/openwisp_notifications/utils.py index 1edecde7..170dff92 100644 --- a/openwisp_notifications/utils.py +++ b/openwisp_notifications/utils.py @@ -1,6 +1,10 @@ from django.conf import settings from django.contrib.sites.models import Site from django.urls import NoReverseMatch, reverse +from django.utils.translation import gettext as _ + +from openwisp_notifications.exceptions import NotificationRenderException +from openwisp_utils.admin_theme.email import send_email def _get_object_link(obj, field, absolute_url=False, *args, **kwargs): @@ -28,3 +32,34 @@ def normalize_unread_count(unread_count): return '99+' else: return unread_count + + +def send_notification_email(notification): + """Send a single email notification""" + try: + subject = notification.email_subject + except NotificationRenderException: + # Do not send email if notification is malformed. + return + url = notification.data.get('url', '') if notification.data else None + description = notification.message + if url: + target_url = url + elif notification.target: + target_url = notification.redirect_view_url + else: + target_url = None + if target_url: + description += _('\n\nFor more information see %(target_url)s.') % { + 'target_url': target_url + } + send_email( + subject, + description, + notification.message, + recipients=[notification.recipient.email], + extra_context={ + 'call_to_action_url': target_url, + 'call_to_action_text': _('Find out more'), + }, + )