Skip to content

Commit 069de84

Browse files
Dhanus3133nemesifierpandafy
authored
[feature] Added Email Batch Summary #132
Implements and closes #132. --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io> Co-authored-by: Gagan Deep <pandafy.dev@gmail.com>
1 parent 95c1618 commit 069de84

File tree

10 files changed

+408
-30
lines changed

10 files changed

+408
-30
lines changed

README.rst

+24
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,30 @@ The default configuration is as follows:
10131013
'max_allowed_backoff': 15,
10141014
}
10151015
1016+
``OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL``
1017+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1018+
1019+
+---------+-----------------------------------+
1020+
| type | ``int`` |
1021+
+---------+-----------------------------------+
1022+
| default | ``1800`` `(30 mins, in seconds)` |
1023+
+---------+-----------------------------------+
1024+
1025+
This setting defines the interval at which the email notifications are sent in batches to users within the specified interval.
1026+
1027+
If you want to send email notifications immediately, then set it to ``0``.
1028+
1029+
``OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT``
1030+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1031+
1032+
+---------+-----------------------------------+
1033+
| type | ``int`` |
1034+
+---------+-----------------------------------+
1035+
| default | ``15`` |
1036+
+---------+-----------------------------------+
1037+
1038+
This setting defines the number of email notifications to be displayed in a batched email.
1039+
10161040
Exceptions
10171041
----------
10181042

openwisp_notifications/base/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def message(self):
139139
@cached_property
140140
def rendered_description(self):
141141
if not self.description:
142-
return
142+
return ''
143143
with notification_render_attributes(self):
144144
data = self.data or {}
145145
desc = self.description.format(notification=self, **data)

openwisp_notifications/handlers.py

+39-28
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from django.db.models.signals import post_delete, post_save
1111
from django.dispatch import receiver
1212
from django.utils import timezone
13-
from django.utils.translation import gettext as _
1413

1514
from openwisp_notifications import settings as app_settings
1615
from openwisp_notifications import tasks
@@ -20,8 +19,8 @@
2019
NOTIFICATION_ASSOCIATED_MODELS,
2120
get_notification_configuration,
2221
)
22+
from openwisp_notifications.utils import send_notification_email
2323
from openwisp_notifications.websockets import handlers as ws_handlers
24-
from openwisp_utils.admin_theme.email import send_email
2524

2625
logger = logging.getLogger(__name__)
2726

@@ -192,34 +191,46 @@ def send_email_notification(sender, instance, created, **kwargs):
192191
if not (email_preference and instance.recipient.email and email_verified):
193192
return
194193

195-
try:
196-
subject = instance.email_subject
197-
except NotificationRenderException:
198-
# Do not send email if notification is malformed.
199-
return
200-
url = instance.data.get('url', '') if instance.data else None
201-
body_text = instance.email_message
202-
if url:
203-
target_url = url
204-
elif instance.target:
205-
target_url = instance.redirect_view_url
206-
else:
207-
target_url = None
208-
if target_url:
209-
body_text += _('\n\nFor more information see %(target_url)s.') % {
210-
'target_url': target_url
211-
}
212-
213-
send_email(
214-
subject=subject,
215-
body_text=body_text,
216-
body_html=instance.email_message,
217-
recipients=[instance.recipient.email],
218-
extra_context={
219-
'call_to_action_url': target_url,
220-
'call_to_action_text': _('Find out more'),
194+
recipient_id = instance.recipient.id
195+
cache_key = f'email_batch_{recipient_id}'
196+
197+
cache_data = cache.get(
198+
cache_key,
199+
{
200+
'last_email_sent_time': None,
201+
'batch_scheduled': False,
202+
'pks': [],
203+
'start_time': None,
204+
'email_id': instance.recipient.email,
221205
},
222206
)
207+
EMAIL_BATCH_INTERVAL = app_settings.EMAIL_BATCH_INTERVAL
208+
209+
if cache_data['last_email_sent_time'] and EMAIL_BATCH_INTERVAL > 0:
210+
# Case 1: Batch email sending logic
211+
if not cache_data['batch_scheduled']:
212+
# Schedule batch email notification task if not already scheduled
213+
tasks.send_batched_email_notifications.apply_async(
214+
(instance.recipient.id,), countdown=EMAIL_BATCH_INTERVAL
215+
)
216+
# Mark batch as scheduled to prevent duplicate scheduling
217+
cache_data['batch_scheduled'] = True
218+
cache_data['pks'] = [instance.id]
219+
cache_data['start_time'] = timezone.now()
220+
cache.set(cache_key, cache_data)
221+
else:
222+
# Add current instance ID to the list of IDs for batch
223+
cache_data['pks'].append(instance.id)
224+
cache.set(cache_key, cache_data)
225+
return
226+
227+
# Case 2: Single email sending logic
228+
# Update the last email sent time and cache the data
229+
if EMAIL_BATCH_INTERVAL > 0:
230+
cache_data['last_email_sent_time'] = timezone.now()
231+
cache.set(cache_key, cache_data, timeout=EMAIL_BATCH_INTERVAL)
232+
233+
send_notification_email(instance)
223234

224235
# flag as emailed
225236
instance.emailed = True

openwisp_notifications/settings.py

+8
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
'openwisp-notifications/audio/notification_bell.mp3',
3838
)
3939

40+
EMAIL_BATCH_INTERVAL = getattr(
41+
settings, 'OPENWISP_NOTIFICATIONS_EMAIL_BATCH_INTERVAL', 30 * 60 # 30 minutes
42+
)
43+
44+
EMAIL_BATCH_DISPLAY_LIMIT = getattr(
45+
settings, 'OPENWISP_NOTIFICATIONS_EMAIL_BATCH_DISPLAY_LIMIT', 15
46+
)
47+
4048
# Remove the leading "/static/" here as it will
4149
# conflict with the "static()" call in context_processors.py.
4250
# This is done for backward compatibility.

openwisp_notifications/static/openwisp-notifications/js/notifications.js

+6
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ function initNotificationDropDown($) {
100100
$('#openwisp_notifications').focus();
101101
}
102102
});
103+
104+
// Show notification widget if URL contains #notifications
105+
if (window.location.hash === '#notifications') {
106+
$('.ow-notification-dropdown').removeClass('ow-hide');
107+
$('.ow-notification-wrapper').trigger('refreshNotificationWidget');
108+
}
103109
}
104110

105111
// Used to convert absolute URLs in notification messages to relative paths

openwisp_notifications/tasks.py

+87
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33
from celery import shared_task
44
from django.contrib.auth import get_user_model
55
from django.contrib.contenttypes.models import ContentType
6+
from django.contrib.sites.models import Site
7+
from django.core.cache import cache
68
from django.db.models import Q
79
from django.db.utils import OperationalError
10+
from django.template.loader import render_to_string
811
from django.utils import timezone
12+
from django.utils.translation import gettext as _
913

14+
from openwisp_notifications import settings as app_settings
1015
from openwisp_notifications import types
1116
from openwisp_notifications.swapper import load_model, swapper_load_model
17+
from openwisp_notifications.utils import send_notification_email
18+
from openwisp_utils.admin_theme.email import send_email
1219
from openwisp_utils.tasks import OpenwispCeleryTask
1320

1421
User = get_user_model()
@@ -202,3 +209,83 @@ def delete_ignore_object_notification(instance_id):
202209
Deletes IgnoreObjectNotification object post it's expiration.
203210
"""
204211
IgnoreObjectNotification.objects.filter(id=instance_id).delete()
212+
213+
214+
@shared_task(base=OpenwispCeleryTask)
215+
def send_batched_email_notifications(instance_id):
216+
"""
217+
Sends a summary of notifications to the specified email address.
218+
"""
219+
if not instance_id:
220+
return
221+
222+
cache_key = f'email_batch_{instance_id}'
223+
cache_data = cache.get(cache_key, {'pks': []})
224+
225+
if not cache_data['pks']:
226+
return
227+
228+
display_limit = app_settings.EMAIL_BATCH_DISPLAY_LIMIT
229+
unsent_notifications_query = Notification.objects.filter(
230+
id__in=cache_data['pks']
231+
).order_by('-timestamp')
232+
notifications_count = unsent_notifications_query.count()
233+
current_site = Site.objects.get_current()
234+
email_id = cache_data.get('email_id')
235+
unsent_notifications = []
236+
237+
# Send individual email if there is only one notification
238+
if notifications_count == 1:
239+
notification = unsent_notifications.first()
240+
send_notification_email(notification)
241+
else:
242+
# Show the amount of notifications according to configured display limit
243+
for notification in unsent_notifications_query[:display_limit]:
244+
url = notification.data.get('url', '') if notification.data else None
245+
if url:
246+
notification.url = url
247+
elif notification.target:
248+
notification.url = notification.redirect_view_url
249+
else:
250+
notification.url = None
251+
252+
unsent_notifications.append(notification)
253+
254+
starting_time = (
255+
cache_data.get('start_time')
256+
.strftime('%B %-d, %Y, %-I:%M %p')
257+
.lower()
258+
.replace('am', 'a.m.')
259+
.replace('pm', 'p.m.')
260+
) + ' UTC'
261+
262+
context = {
263+
'notifications': unsent_notifications[:display_limit],
264+
'notifications_count': notifications_count,
265+
'site_name': current_site.name,
266+
'start_time': starting_time,
267+
}
268+
269+
extra_context = {}
270+
if notifications_count > display_limit:
271+
extra_context = {
272+
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
273+
'call_to_action_text': _('View all Notifications'),
274+
}
275+
context.update(extra_context)
276+
277+
html_content = render_to_string('emails/batch_email.html', context)
278+
plain_text_content = render_to_string('emails/batch_email.txt', context)
279+
notifications_count = min(notifications_count, display_limit)
280+
281+
send_email(
282+
subject=f'[{current_site.name}] {notifications_count} new notifications since {starting_time}',
283+
body_text=plain_text_content,
284+
body_html=html_content,
285+
recipients=[email_id],
286+
extra_context=extra_context,
287+
)
288+
289+
unsent_notifications_query.update(emailed=True)
290+
Notification.objects.bulk_update(unsent_notifications_query, ['emailed'])
291+
cache.delete(cache_key)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
{% block styles %}
2+
<style type="text/css">
3+
.alert {
4+
border: 1px solid #e0e0e0;
5+
border-radius: 5px;
6+
margin-bottom: 10px;
7+
padding: 10px;
8+
}
9+
.alert.error {
10+
background-color: #ffefef;
11+
}
12+
.alert.info {
13+
background-color: #f0f0f0;
14+
}
15+
.alert.success {
16+
background-color: #e6f9e8;
17+
}
18+
.alert h2 {
19+
margin: 0 0 5px 0;
20+
font-size: 16px;
21+
}
22+
.alert h2 .title {
23+
display: inline-block;
24+
max-width: 80%;
25+
white-space: nowrap;
26+
overflow: hidden;
27+
text-overflow: ellipsis;
28+
vertical-align: middle;
29+
}
30+
.alert.error h2 {
31+
color: #d9534f;
32+
}
33+
.alert.info h2 {
34+
color: #333333;
35+
}
36+
.alert.success h2 {
37+
color: #1c8828;
38+
}
39+
.alert p {
40+
margin: 0;
41+
font-size: 14px;
42+
color: #666;
43+
}
44+
.alert .title p {
45+
display: inline;
46+
overflow: hidden;
47+
text-overflow: ellipsis;
48+
}
49+
.badge {
50+
display: inline-block;
51+
padding: 2px 8px;
52+
border-radius: 3px;
53+
font-size: 12px;
54+
font-weight: bold;
55+
text-transform: uppercase;
56+
margin-right: 5px;
57+
color: white;
58+
}
59+
.badge.error {
60+
background-color: #d9534f;
61+
}
62+
.badge.info {
63+
background-color: #333333;
64+
}
65+
.badge.success {
66+
background-color: #1c8828;
67+
}
68+
.alert a {
69+
text-decoration: none;
70+
}
71+
.alert.error a {
72+
color: #d9534f;
73+
}
74+
.alert.info a {
75+
color: #333333;
76+
}
77+
.alert.success a {
78+
color: #1c8828;
79+
}
80+
.alert a:hover {
81+
text-decoration: underline;
82+
}
83+
</style>
84+
{% endblock styles %}
85+
86+
{% block mail_body %}
87+
<div>
88+
{% for notification in notifications %}
89+
<div class="alert {{ notification.level }}">
90+
<h2>
91+
<span class="badge {{ notification.level }}">{{ notification.level|upper }}</span>
92+
<span class="title">
93+
{% if notification.url %}
94+
<a href="{{ notification.url }}" target="_blank">{{ notification.email_message }}</a>
95+
{% else %}
96+
{{ notification.email_message }}
97+
{% endif %}
98+
</span>
99+
</h2>
100+
<p>{{ notification.timestamp|date:"F j, Y, g:i a" }}</p>
101+
{% if notification.rendered_description %}
102+
<p>{{ notification.rendered_description|safe }}</p>
103+
{% endif %}
104+
</div>
105+
{% endfor %}
106+
</div>
107+
{% endblock mail_body %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{% load i18n %}
2+
3+
[{{ site_name }}] {{ notifications_count }} {% translate "new notifications since" %} {{ start_time }}
4+
5+
{% for notification in notifications %}
6+
- {{ notification.email_message }}{% if notification.rendered_description %}
7+
{% translate "Description" %}: {{ notification.rendered_description }}{% endif %}
8+
{% translate "Date & Time" %}: {{ notification.timestamp|date:"F j, Y, g:i a" }}{% if notification.url %}
9+
{% translate "URL" %}: {{ notification.url }}{% endif %}
10+
{% endfor %}
11+
12+
{% if call_to_action_url %}
13+
{{ call_to_action_text }}: {{ call_to_action_url }}
14+
{% endif %}

0 commit comments

Comments
 (0)