Skip to content

Commit bab56a5

Browse files
Dhanus3133nemesifierpandafy
committed
[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 5f73a20 commit bab56a5

File tree

9 files changed

+385
-29
lines changed

9 files changed

+385
-29
lines changed

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-27
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
NOTIFICATION_ASSOCIATED_MODELS,
2727
get_notification_configuration,
2828
)
29+
from openwisp_notifications.utils import send_notification_email
2930
from openwisp_notifications.websockets import handlers as ws_handlers
30-
from openwisp_utils.admin_theme.email import send_email
3131

3232
logger = logging.getLogger(__name__)
3333

@@ -198,34 +198,46 @@ def send_email_notification(sender, instance, created, **kwargs):
198198
if not (email_preference and instance.recipient.email and email_verified):
199199
return
200200

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

230242
# flag as emailed
231243
instance.emailed = True

openwisp_notifications/settings.py

+9
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@
3636
'OPENWISP_NOTIFICATIONS_SOUND',
3737
'openwisp-notifications/audio/notification_bell.mp3',
3838
)
39+
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+
3948
# Remove the leading "/static/" here as it will
4049
# conflict with the "static()" call in context_processors.py.
4150
# This is done for backward compatibility.

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

+6
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ function initNotificationDropDown($) {
106106
$("#openwisp_notifications").focus();
107107
}
108108
});
109+
110+
// Show notification widget if URL contains #notifications
111+
if (window.location.hash === "#notifications") {
112+
$(".ow-notification-dropdown").removeClass("ow-hide");
113+
$(".ow-notification-wrapper").trigger("refreshNotificationWidget");
114+
}
109115
}
110116

111117
// 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()
@@ -201,3 +208,83 @@ def delete_ignore_object_notification(instance_id):
201208
Deletes IgnoreObjectNotification object post it's expiration.
202209
"""
203210
IgnoreObjectNotification.objects.filter(id=instance_id).delete()
211+
212+
213+
@shared_task(base=OpenwispCeleryTask)
214+
def send_batched_email_notifications(instance_id):
215+
"""
216+
Sends a summary of notifications to the specified email address.
217+
"""
218+
if not instance_id:
219+
return
220+
221+
cache_key = f'email_batch_{instance_id}'
222+
cache_data = cache.get(cache_key, {'pks': []})
223+
224+
if not cache_data['pks']:
225+
return
226+
227+
display_limit = app_settings.EMAIL_BATCH_DISPLAY_LIMIT
228+
unsent_notifications_query = Notification.objects.filter(
229+
id__in=cache_data['pks']
230+
).order_by('-timestamp')
231+
notifications_count = unsent_notifications_query.count()
232+
current_site = Site.objects.get_current()
233+
email_id = cache_data.get('email_id')
234+
unsent_notifications = []
235+
236+
# Send individual email if there is only one notification
237+
if notifications_count == 1:
238+
notification = unsent_notifications.first()
239+
send_notification_email(notification)
240+
else:
241+
# Show the amount of notifications according to configured display limit
242+
for notification in unsent_notifications_query[:display_limit]:
243+
url = notification.data.get('url', '') if notification.data else None
244+
if url:
245+
notification.url = url
246+
elif notification.target:
247+
notification.url = notification.redirect_view_url
248+
else:
249+
notification.url = None
250+
251+
unsent_notifications.append(notification)
252+
253+
starting_time = (
254+
cache_data.get('start_time')
255+
.strftime('%B %-d, %Y, %-I:%M %p')
256+
.lower()
257+
.replace('am', 'a.m.')
258+
.replace('pm', 'p.m.')
259+
) + ' UTC'
260+
261+
context = {
262+
'notifications': unsent_notifications[:display_limit],
263+
'notifications_count': notifications_count,
264+
'site_name': current_site.name,
265+
'start_time': starting_time,
266+
}
267+
268+
extra_context = {}
269+
if notifications_count > display_limit:
270+
extra_context = {
271+
'call_to_action_url': f"https://{current_site.domain}/admin/#notifications",
272+
'call_to_action_text': _('View all Notifications'),
273+
}
274+
context.update(extra_context)
275+
276+
html_content = render_to_string('emails/batch_email.html', context)
277+
plain_text_content = render_to_string('emails/batch_email.txt', context)
278+
notifications_count = min(notifications_count, display_limit)
279+
280+
send_email(
281+
subject=f'[{current_site.name}] {notifications_count} new notifications since {starting_time}',
282+
body_text=plain_text_content,
283+
body_html=html_content,
284+
recipients=[email_id],
285+
extra_context=extra_context,
286+
)
287+
288+
unsent_notifications_query.update(emailed=True)
289+
Notification.objects.bulk_update(unsent_notifications_query, ['emailed'])
290+
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)