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 %} +
{{ notification.timestamp|date:"F j, Y, g:i a" }}
+ {% if show_notification_description %} +{{ notification.rendered_description|safe }}
+ {% endif %} +Default notification with' ' default verb and level info by' - f' ' + f' ' 'Tester Tester (test org)