Skip to content

Commit

Permalink
[feature] Added Email Batch Summary #132
Browse files Browse the repository at this point in the history
Implements and closes #132.

---------

Co-authored-by: Federico Capoano <[email protected]>
Co-authored-by: Gagan Deep <[email protected]>
  • Loading branch information
3 people authored Jul 30, 2024
1 parent 95c1618 commit 069de84
Show file tree
Hide file tree
Showing 10 changed files with 408 additions and 30 deletions.
24 changes: 24 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------

Expand Down
2 changes: 1 addition & 1 deletion openwisp_notifications/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
67 changes: 39 additions & 28 deletions openwisp_notifications/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,8 +19,8 @@
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__)

Expand Down Expand Up @@ -192,34 +191,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,
},
)
EMAIL_BATCH_INTERVAL = app_settings.EMAIL_BATCH_INTERVAL

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
Expand Down
8 changes: 8 additions & 0 deletions openwisp_notifications/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ function initNotificationDropDown($) {
$('#openwisp_notifications').focus();
}
});

// Show notification widget if URL contains #notifications
if (window.location.hash === '#notifications') {
$('.ow-notification-dropdown').removeClass('ow-hide');
$('.ow-notification-wrapper').trigger('refreshNotificationWidget');
}
}

// Used to convert absolute URLs in notification messages to relative paths
Expand Down
87 changes: 87 additions & 0 deletions openwisp_notifications/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
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

User = get_user_model()
Expand Down Expand Up @@ -202,3 +209,83 @@ 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_query = Notification.objects.filter(
id__in=cache_data['pks']
).order_by('-timestamp')
notifications_count = unsent_notifications_query.count()
current_site = Site.objects.get_current()
email_id = cache_data.get('email_id')
unsent_notifications = []

# 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
for notification in unsent_notifications_query[:display_limit]:
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

unsent_notifications.append(notification)

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[:display_limit],
'notifications_count': notifications_count,
'site_name': current_site.name,
'start_time': starting_time,
}

extra_context = {}
if notifications_count > display_limit:
extra_context = {
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
'call_to_action_text': _('View all Notifications'),
}
context.update(extra_context)

html_content = render_to_string('emails/batch_email.html', context)
plain_text_content = render_to_string('emails/batch_email.txt', context)
notifications_count = min(notifications_count, display_limit)

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_query.update(emailed=True)
Notification.objects.bulk_update(unsent_notifications_query, ['emailed'])
cache.delete(cache_key)
107 changes: 107 additions & 0 deletions openwisp_notifications/templates/emails/batch_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{% block styles %}
<style type="text/css">
.alert {
border: 1px solid #e0e0e0;
border-radius: 5px;
margin-bottom: 10px;
padding: 10px;
}
.alert.error {
background-color: #ffefef;
}
.alert.info {
background-color: #f0f0f0;
}
.alert.success {
background-color: #e6f9e8;
}
.alert h2 {
margin: 0 0 5px 0;
font-size: 16px;
}
.alert h2 .title {
display: inline-block;
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.alert.error h2 {
color: #d9534f;
}
.alert.info h2 {
color: #333333;
}
.alert.success h2 {
color: #1c8828;
}
.alert p {
margin: 0;
font-size: 14px;
color: #666;
}
.alert .title p {
display: inline;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
margin-right: 5px;
color: white;
}
.badge.error {
background-color: #d9534f;
}
.badge.info {
background-color: #333333;
}
.badge.success {
background-color: #1c8828;
}
.alert a {
text-decoration: none;
}
.alert.error a {
color: #d9534f;
}
.alert.info a {
color: #333333;
}
.alert.success a {
color: #1c8828;
}
.alert a:hover {
text-decoration: underline;
}
</style>
{% endblock styles %}

{% block mail_body %}
<div>
{% for notification in notifications %}
<div class="alert {{ notification.level }}">
<h2>
<span class="badge {{ notification.level }}">{{ notification.level|upper }}</span>
<span class="title">
{% if notification.url %}
<a href="{{ notification.url }}" target="_blank">{{ notification.email_message }}</a>
{% else %}
{{ notification.email_message }}
{% endif %}
</span>
</h2>
<p>{{ notification.timestamp|date:"F j, Y, g:i a" }}</p>
{% if notification.rendered_description %}
<p>{{ notification.rendered_description|safe }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endblock mail_body %}
14 changes: 14 additions & 0 deletions openwisp_notifications/templates/emails/batch_email.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% load i18n %}

[{{ site_name }}] {{ notifications_count }} {% translate "new notifications since" %} {{ start_time }}

{% for notification in notifications %}
- {{ notification.email_message }}{% if notification.rendered_description %}
{% translate "Description" %}: {{ notification.rendered_description }}{% endif %}
{% translate "Date & Time" %}: {{ notification.timestamp|date:"F j, Y, g:i a" }}{% if notification.url %}
{% translate "URL" %}: {{ notification.url }}{% endif %}
{% endfor %}

{% if call_to_action_url %}
{{ call_to_action_text }}: {{ call_to_action_url }}
{% endif %}
Loading

0 comments on commit 069de84

Please sign in to comment.