diff --git a/config/celery.py b/config/celery.py index bf83608..cc1d037 100644 --- a/config/celery.py +++ b/config/celery.py @@ -30,6 +30,10 @@ app.conf.beat_schedule = { 'task': 'notifications.tasks.process_recurring_tasks', 'schedule': crontab(hour=0, minute=0), # Daily at midnight }, + 'send-scheduled-reminders': { + 'task': 'notifications.tasks.send_scheduled_reminders', + 'schedule': crontab(minute='*/5'), # Every 5 minutes - these are time-sensitive + }, } diff --git a/notifications/admin.py b/notifications/admin.py index 45a75bc..3a732b1 100644 --- a/notifications/admin.py +++ b/notifications/admin.py @@ -21,8 +21,8 @@ class NotificationAdmin(admin.ModelAdmin): class ScheduledReminderAdmin(admin.ModelAdmin): """Admin for ScheduledReminder.""" - list_display = ['task', 'remind_at', 'is_sent', 'sent_at', 'created_at'] - list_filter = ['is_sent', 'remind_at'] + list_display = ['task', 'reminder_type', 'remind_at', 'is_sent', 'sent_at', 'created_at'] + list_filter = ['reminder_type', 'is_sent', 'remind_at'] search_fields = ['task__title'] ordering = ['remind_at'] raw_id_fields = ['task'] diff --git a/notifications/migrations/0004_scheduledreminder_reminder_type_and_more.py b/notifications/migrations/0004_scheduledreminder_reminder_type_and_more.py new file mode 100644 index 0000000..0513ae0 --- /dev/null +++ b/notifications/migrations/0004_scheduledreminder_reminder_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-09-05 15:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0003_initial'), + ] + + operations = [ + migrations.AddField( + model_name='scheduledreminder', + name='reminder_type', + field=models.CharField(choices=[('reminder', 'Before Due'), ('due_soon', 'Due Now'), ('overdue', 'Overdue')], default='reminder', max_length=20), + ), + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('reminder', 'Task Reminder'), ('due_soon', 'Due Soon'), ('overdue', 'Overdue'), ('shared', 'Task Shared'), ('comment', 'Comment'), ('daily_email', 'Daily Email')], max_length=20), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index 8c77d4b..8f9afec 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -50,14 +50,23 @@ class Notification(models.Model): class ScheduledReminder(models.Model): """ - Tracks scheduled reminders for tasks. + Tracks scheduled reminders for tasks. Rows are (re)created by + Task.reschedule_reminders() whenever a task's due info changes, and + consumed by notifications.tasks.send_scheduled_reminders(). """ + REMINDER_TYPES = [ + ('reminder', 'Before Due'), + ('due_soon', 'Due Now'), + ('overdue', 'Overdue'), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) task = models.ForeignKey( 'tasks.Task', on_delete=models.CASCADE, related_name='scheduled_reminders' ) + reminder_type = models.CharField(max_length=20, choices=REMINDER_TYPES, default='reminder') remind_at = models.DateTimeField() is_sent = models.BooleanField(default=False) sent_at = models.DateTimeField(null=True, blank=True) @@ -68,4 +77,4 @@ class ScheduledReminder(models.Model): ordering = ['remind_at'] def __str__(self): - return f"Reminder for {self.task.title} at {self.remind_at}" + return f"{self.get_reminder_type_display()} for {self.task.title} at {self.remind_at}" diff --git a/notifications/serializers.py b/notifications/serializers.py index aa623dd..4a4179e 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -25,5 +25,5 @@ class ScheduledReminderSerializer(serializers.ModelSerializer): class Meta: model = ScheduledReminder - fields = ['id', 'task', 'remind_at', 'is_sent', 'sent_at', 'created_at'] + fields = ['id', 'task', 'reminder_type', 'remind_at', 'is_sent', 'sent_at', 'created_at'] read_only_fields = ['id', 'is_sent', 'sent_at', 'created_at'] diff --git a/notifications/tasks.py b/notifications/tasks.py index 2dd371d..d3f4a8f 100644 --- a/notifications/tasks.py +++ b/notifications/tasks.py @@ -16,6 +16,45 @@ from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) +def notify_user(user, title, body, notification_type, task=None, url='/'): + """ + Send a notification to a user via whichever of email/push they have + enabled, and record it in the Notification log. Returns True if it went + out on at least one channel. + """ + from .models import Notification + + notified = False + + if user.email_notifications and user.email_verified: + try: + send_mail( + subject=title, + message=body, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, + ) + notified = True + except Exception as e: + logger.error(f"Failed to email {user.email}: {e}") + + if user.push_notifications: + send_web_push_to_user(user, title=title, body=body, url=url) + notified = True + + if notified: + Notification.objects.create( + user=user, + notification_type=notification_type, + title=title, + message=body, + task=task, + ) + + return notified + + def send_web_push_to_user(user, title, body, url='/'): """Send a Web Push notification to all of a user's registered web devices.""" if not settings.VAPID_PRIVATE_KEY: @@ -183,6 +222,42 @@ def send_daily_task_email(): return notifications_sent +REMINDER_MESSAGES = { + 'reminder': lambda task: (f'Upcoming: {task.title}', f'"{task.title}" is due soon.'), + 'due_soon': lambda task: (f'Due now: {task.title}', f'"{task.title}" is due now.'), + 'overdue': lambda task: (f'Overdue: {task.title}', f'"{task.title}" is overdue.'), +} + + +@shared_task +def send_scheduled_reminders(): + """ + Send "before due", "due now", and "overdue" notifications from + ScheduledReminder rows created by Task.reschedule_reminders(). Runs + frequently (every few minutes) since these are time-sensitive, unlike + the once-a-day digest above. + """ + from .models import ScheduledReminder + + due_reminders = ScheduledReminder.objects.filter( + is_sent=False, remind_at__lte=timezone.now() + ).select_related('task', 'task__user') + + sent_count = 0 + for reminder in due_reminders: + task = reminder.task + if not task.is_deleted and task.status not in ('completed', 'cancelled'): + title, body = REMINDER_MESSAGES[reminder.reminder_type](task) + if notify_user(task.user, title, body, reminder.reminder_type, task=task, url=f'/tasks/{task.id}/'): + sent_count += 1 + + reminder.is_sent = True + reminder.sent_at = timezone.now() + reminder.save(update_fields=['is_sent', 'sent_at']) + + return sent_count + + @shared_task def process_recurring_tasks(): """ diff --git a/notifications/tests.py b/notifications/tests.py index 7ce503c..e018fbf 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -1,3 +1,200 @@ -from django.test import TestCase +from datetime import date, datetime, time as dt_time, timedelta +from unittest.mock import patch -# Create your tests here. +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase +from django.utils import timezone as django_timezone + +from notifications.models import ScheduledReminder +from notifications.tasks import send_scheduled_reminders +from tasks.models import Task + +User = get_user_model() + + +class RescheduleRemindersTests(TestCase): + """Tests for Task.reschedule_reminders(), triggered from Task.save().""" + + def setUp(self): + self.user = User.objects.create_user( + username='reminderuser', + email='reminderuser@example.com', + password='testpass123', + default_reminder_minutes=30, + ) + + def reminder_types(self, task): + return set( + ScheduledReminder.objects.filter(task=task, is_sent=False).values_list('reminder_type', flat=True) + ) + + def test_due_time_task_gets_all_three_reminders(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + self.assertEqual(self.reminder_types(task), {'reminder', 'due_soon', 'overdue'}) + + before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') + due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') + overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue') + + expected_due = django_timezone.make_aware(datetime(2026, 1, 15, 17, 0, 0)) + self.assertEqual(due_soon.remind_at, expected_due) + self.assertEqual(overdue.remind_at, expected_due) + self.assertEqual(before_due.remind_at, expected_due - timedelta(minutes=30)) + + def test_date_only_task_only_gets_overdue_reminder_next_day(self): + task = Task.objects.create(user=self.user, title='Task', due_date=date(2026, 1, 15)) + self.assertEqual(self.reminder_types(task), {'overdue'}) + overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue') + self.assertEqual(overdue.remind_at, django_timezone.make_aware(datetime(2026, 1, 16, 0, 0, 0))) + + def test_no_reminder_minutes_skips_before_due_reminder(self): + self.user.default_reminder_minutes = 0 + self.user.save() + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + self.assertEqual(self.reminder_types(task), {'due_soon', 'overdue'}) + + def test_explicit_reminder_at_overrides_default_minutes(self): + custom_reminder = django_timezone.make_aware(datetime(2026, 1, 15, 9, 0, 0)) + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + reminder_at=custom_reminder, + ) + before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') + self.assertEqual(before_due.remind_at, custom_reminder) + + def test_no_due_date_gets_no_reminders(self): + task = Task.objects.create(user=self.user, title='Task') + self.assertEqual(self.reminder_types(task), set()) + + def test_completing_task_clears_pending_reminders(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + self.assertTrue(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) + task.status = 'completed' + task.save() + self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) + + def test_deleting_task_clears_pending_reminders(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + task.is_deleted = True + task.save() + self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) + + def test_changing_due_date_replaces_reminders(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + task.due_date = date(2026, 2, 1) + task.save() + self.assertEqual(ScheduledReminder.objects.filter(task=task, is_sent=False).count(), 3) + due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') + self.assertEqual(due_soon.remind_at, django_timezone.make_aware(datetime(2026, 2, 1, 17, 0, 0))) + + def test_unrelated_field_save_does_not_duplicate_reminders(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + task.title = 'Renamed task' + task.save() + self.assertEqual(ScheduledReminder.objects.filter(task=task, is_sent=False).count(), 3) + + +class SendScheduledRemindersTests(TestCase): + """Tests for the send_scheduled_reminders Celery task.""" + + def setUp(self): + self.user = User.objects.create_user( + username='sendreminderuser', + email='sendreminderuser@example.com', + password='testpass123', + email_notifications=True, + push_notifications=False, + email_verified=True, + default_reminder_minutes=30, + ) + + def test_due_reminder_sends_email_and_marks_sent(self): + task = Task.objects.create( + user=self.user, title='Water the plants', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + reminder = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') + + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = reminder.remind_at + timedelta(seconds=1) + sent_count = send_scheduled_reminders() + + self.assertEqual(sent_count, 3) # reminder, due_soon, overdue all past remind_at by then + self.assertEqual(len(mail.outbox), 3) + reminder.refresh_from_db() + self.assertTrue(reminder.is_sent) + self.assertIsNotNone(reminder.sent_at) + + def test_future_reminder_not_sent_yet(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 8, 0, 0)) + sent_count = send_scheduled_reminders() + + self.assertEqual(sent_count, 0) + self.assertEqual(len(mail.outbox), 0) + + def test_completed_task_reminder_marked_sent_without_notifying(self): + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + remind_at = ScheduledReminder.objects.get(task=task, reminder_type='due_soon').remind_at + task.status = 'completed' + task.save() # reschedule_reminders() already clears pending reminders for the task + + # Simulate a stale reminder surviving anyway (e.g. a race with the poller). + stale = ScheduledReminder.objects.create(task=task, reminder_type='due_soon', remind_at=remind_at) + + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = remind_at + timedelta(seconds=1) + send_scheduled_reminders() + + self.assertEqual(len(mail.outbox), 0) + stale.refresh_from_db() + self.assertTrue(stale.is_sent) + + def test_channel_choice_email_only(self): + self.user.push_notifications = False + self.user.save() + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + reminder = ScheduledReminder.objects.get(task=task, reminder_type='reminder') + + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = reminder.remind_at + timedelta(seconds=1) + send_scheduled_reminders() + + sent_subjects = [m.subject for m in mail.outbox] + self.assertIn(f'Upcoming: {task.title}', sent_subjects) + + def test_channel_choice_none_still_marks_sent(self): + self.user.email_notifications = False + self.user.push_notifications = False + self.user.save() + task = Task.objects.create( + user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), + ) + reminder = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') + + with patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = reminder.remind_at + timedelta(seconds=1) + send_scheduled_reminders() + + self.assertEqual(len(mail.outbox), 0) + reminder.refresh_from_db() + self.assertTrue(reminder.is_sent) diff --git a/tasks/models.py b/tasks/models.py index fb3d051..9bb4b4f 100644 --- a/tasks/models.py +++ b/tasks/models.py @@ -136,9 +136,18 @@ class Task(models.Model): db_table = 'tasks' ordering = ['sort_order', '-priority', 'due_date', 'created_at'] + # Fields that affect when/whether reminder notifications should fire. + REMINDER_TRACKED_FIELDS = ('due_date', 'due_time', 'reminder_at', 'status', 'is_deleted') + def __str__(self): return self.title + @classmethod + def from_db(cls, db, field_names, values): + instance = super().from_db(db, field_names, values) + instance._loaded_values = dict(zip(field_names, values)) + return instance + def save(self, *args, **kwargs): # Auto-set completed_at when status changes to completed if self.status == 'completed' and self.completed_at is None: @@ -146,7 +155,81 @@ class Task(models.Model): self.completed_at = timezone.now() elif self.status != 'completed': self.completed_at = None + + is_new = self._state.adding + loaded_values = getattr(self, '_loaded_values', None) + reminder_fields_changed = is_new or loaded_values is None or any( + loaded_values.get(field) != getattr(self, field) for field in self.REMINDER_TRACKED_FIELDS + ) + super().save(*args, **kwargs) + self._loaded_values = {field: getattr(self, field) for field in self.REMINDER_TRACKED_FIELDS} + + if reminder_fields_changed: + self.reschedule_reminders() + + def _due_and_overdue_moments(self): + """ + Returns (due_moment, overdue_moment) as timezone-aware datetimes in + the owner's timezone, or (None, None) if there's nothing to schedule + against. Mirrors is_overdue's rule that a date-only due task doesn't + flip overdue until the day after due_date. + """ + if not self.due_date: + return None, None + + from datetime import datetime, time as dt_time, timedelta + from zoneinfo import ZoneInfo + + try: + user_tz = ZoneInfo(self.user.timezone) + except Exception: + user_tz = ZoneInfo('UTC') + + if self.due_time: + due_moment = datetime.combine(self.due_date, self.due_time, tzinfo=user_tz) + return due_moment, due_moment + + overdue_moment = datetime.combine(self.due_date + timedelta(days=1), dt_time.min, tzinfo=user_tz) + return None, overdue_moment + + def reschedule_reminders(self): + """ + Recompute this task's ScheduledReminder rows from its current + due_date/due_time/reminder_at/status. Safe to call any time due + info changes - clears out not-yet-sent reminders and, if the task + is still active and due, schedules fresh ones for "before due" + (from reminder_at, or user.default_reminder_minutes before due), + "due now", and "overdue". + """ + from datetime import timedelta + from notifications.models import ScheduledReminder + + ScheduledReminder.objects.filter(task=self, is_sent=False).delete() + + if self.is_deleted or self.status in ('completed', 'cancelled'): + return + + due_moment, overdue_moment = self._due_and_overdue_moments() + reminders = [] + + if due_moment: + if self.reminder_at: + before_due_moment = self.reminder_at + elif self.user.default_reminder_minutes: + before_due_moment = due_moment - timedelta(minutes=self.user.default_reminder_minutes) + else: + before_due_moment = None + + if before_due_moment: + reminders.append(ScheduledReminder(task=self, reminder_type='reminder', remind_at=before_due_moment)) + reminders.append(ScheduledReminder(task=self, reminder_type='due_soon', remind_at=due_moment)) + + if overdue_moment: + reminders.append(ScheduledReminder(task=self, reminder_type='overdue', remind_at=overdue_moment)) + + if reminders: + ScheduledReminder.objects.bulk_create(reminders) @property def is_overdue(self):