Skip to content

Commit fbf7640

Browse files
authored
Notification max retries (#953)
* deactivate/delete failed notifications (issue #950) * Added notifications tests
1 parent 5ce031f commit fbf7640

File tree

3 files changed

+144
-4
lines changed

3 files changed

+144
-4
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from django.core.management import BaseCommand
2+
from django.utils import timezone
3+
from actions.models import Notification
4+
5+
6+
class Command(BaseCommand):
7+
help = "Delete old or expired notifications."
8+
9+
def add_arguments(self, parser):
10+
parser.add_argument(
11+
'--n-days-old', type=int, default=30,
12+
help='Only delete notifications where send_at is older than n days')
13+
parser.add_argument(
14+
'--status', help='Only delete notifications with this status', type=str, default=None)
15+
parser.add_argument(
16+
'--dry-run', help='Simulate deletion without actually deleting', action='store_true')
17+
18+
def handle(self, *args, **options):
19+
n_days_old = options['n_days_old']
20+
status = options['status']
21+
cutoff_date = timezone.now() - timezone.timedelta(days=n_days_old)
22+
23+
notifications = Notification.objects.filter(send_at__lt=cutoff_date)
24+
25+
if status:
26+
notifications = notifications.filter(status=status)
27+
28+
if options['dry_run']:
29+
message = f'Dry run: {notifications.count()} notifications found'
30+
if status:
31+
message += f' with status "{status}"'
32+
self.stdout.write(self.style.NOTICE(message))
33+
else:
34+
_, deleted_count = notifications.delete()
35+
message = f'Deleted {deleted_count["actions.Notification"]} notifications'
36+
if status:
37+
message += f' with status "{status}"'
38+
self.stdout.write(self.style.SUCCESS(message))

alyx/actions/models.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,11 +460,20 @@ def create_notification(
460460
return notif
461461

462462

463-
def send_pending_emails():
463+
def send_pending_emails(max_resend=3):
464464
"""Send all pending notifications."""
465465
notifications = Notification.objects.filter(status='to-send', send_at__lte=timezone.now())
466466
for notification in notifications:
467-
notification.send_if_needed()
467+
success = notification.send_if_needed()
468+
# If not successful, and max_resend reached, set to no-send.
469+
if (
470+
not success and
471+
max_resend is not None and
472+
notification.ready_to_send() and
473+
len((notification.json or {}).get('failed_attempts', [])) >= max_resend
474+
):
475+
notification.status = 'no-send'
476+
notification.save()
468477

469478

470479
class Notification(BaseModel):
@@ -499,11 +508,22 @@ def send_if_needed(self):
499508
logger.warning("Email not ready to send.")
500509
return False
501510
emails = [user.email for user in self.users.all() if user.email]
502-
if alyx_mail(emails, self.title, self.message):
511+
if succeeded := alyx_mail(emails, self.title, self.message):
503512
self.status = 'sent'
504513
self.sent_at = timezone.now()
505514
self.save()
506515
return True
516+
elif succeeded is False:
517+
json = self.json or {}
518+
if 'failed_attempts' in json:
519+
json['failed_attempts'].append(timezone.now().isoformat())
520+
else:
521+
json['failed_attempts'] = [timezone.now().isoformat()]
522+
self.json = json
523+
self.save()
524+
return False
525+
else: # alyx_mail returns None when email sending is disabled
526+
return False
507527

508528
def __str__(self):
509529
return "<Notification '%s' (%s) %s>" % (self.title, self.status, self.send_at)

alyx/actions/tests.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import datetime
2+
from io import StringIO
3+
from unittest import mock
24
import numpy as np
35
from django.test import TestCase
46
from django.utils import timezone
7+
from django.core.management import call_command
58

69
from alyx import base
710
from actions.water_control import to_date
811
from actions.models import (
912
WaterAdministration, WaterRestriction, WaterType, Weighing,
10-
Notification, NotificationRule, create_notification, Surgery, ProcedureType)
13+
Notification, NotificationRule, create_notification, Surgery, ProcedureType,
14+
send_pending_emails)
1115
from actions.notifications import check_water_administration, check_weighed
1216
from misc.models import LabMember, LabMembership, Lab
1317
from subjects.models import Subject
@@ -289,3 +293,81 @@ def _assert_users(users, expected):
289293
nr.subjects_scope = 'none'
290294
nr.save()
291295
_assert_users([self.user2], [self.user2])
296+
297+
def test_send_pending_emails(self):
298+
"""Test for sending pending notification emails."""
299+
n1 = Notification.objects.create(
300+
title='Test notification 1',
301+
message='This is a test notification.',
302+
send_at=timezone.now() - datetime.timedelta(minutes=1)
303+
)
304+
n2 = Notification.objects.create(
305+
title='Test notification 2',
306+
message='This is another test notification.',
307+
status='no-send',
308+
send_at=timezone.now() - datetime.timedelta(minutes=1)
309+
)
310+
# Check ready_to_send method
311+
self.assertTrue(n1.ready_to_send())
312+
self.assertFalse(n2.ready_to_send())
313+
# Call the function to send pending emails
314+
with mock.patch('actions.models.alyx_mail', return_value=False) as mock_mail:
315+
send_pending_emails()
316+
# Should have been called once with the first notification
317+
mock_mail.assert_called_once_with([], n1.title, n1.message)
318+
json_field = Notification.objects.get(id=n1.id).json or {}
319+
self.assertIn('failed_attempts', json_field)
320+
self.assertEqual(len(json_field['failed_attempts']), 1)
321+
# Call the function to send pending emails
322+
with mock.patch('actions.models.alyx_mail', return_value=False) as mock_mail:
323+
send_pending_emails(max_resend=1)
324+
self.assertEqual('no-send', Notification.objects.get(id=n1.id).status)
325+
# Create two notifications to be sent
326+
Notification.objects.create(
327+
title='Test notification 3',
328+
message='This is a test notification.',
329+
send_at=timezone.now() - datetime.timedelta(minutes=1)
330+
)
331+
Notification.objects.create(
332+
title='Test notification 4',
333+
message='This is another test notification.',
334+
send_at=timezone.now() - datetime.timedelta(minutes=1)
335+
)
336+
with mock.patch('actions.models.alyx_mail', return_value=True) as mock_mail:
337+
send_pending_emails(max_resend=1)
338+
# Check that notifications are marked as emailed
339+
notifs = Notification.objects.filter(status='sent', sent_at__isnull=False)
340+
self.assertEqual(notifs.count(), 2)
341+
342+
343+
class TestManagementTasks(base.BaseTests):
344+
"""Tests for the tasks management command."""
345+
346+
def test_send_pending_notifications_command(self):
347+
"""Test for send_pending_notifications management command."""
348+
out = StringIO()
349+
with mock.patch('actions.models.send_pending_emails') as mock_send:
350+
call_command('send_pending_notifications', stdout=out)
351+
mock_send.assert_called_once()
352+
353+
def test_delete_expired_notifications_command(self):
354+
"""Test for delete_expired_notifications management command."""
355+
# Create some old notifications
356+
old_date = timezone.now() - datetime.timedelta(days=365)
357+
for i in range(5):
358+
Notification.objects.create(
359+
title=f'Old notification {i}',
360+
message='This is an old notification.',
361+
send_at=old_date,
362+
sent_at=old_date,
363+
status='sent'
364+
)
365+
out = StringIO()
366+
call_command('delete_expired_notifications',
367+
n_days_old=180, status='sent', stdout=out, dry_run=True)
368+
self.assertIn('Dry run: 5 notifications found with status "sent"', out.getvalue())
369+
self.assertEqual(Notification.objects.count(), 5)
370+
out = StringIO()
371+
call_command('delete_expired_notifications', n_days_old=180, status='sent', stdout=out)
372+
self.assertIn('Deleted 5 notifications with status "sent"', out.getvalue())
373+
self.assertEqual(Notification.objects.count(), 0)

0 commit comments

Comments
 (0)