diff --git a/backend/letters/migrations/0008_letter_notified_at.py b/backend/letters/migrations/0008_letter_notified_at.py new file mode 100644 index 0000000..b1da3b7 --- /dev/null +++ b/backend/letters/migrations/0008_letter_notified_at.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.4 on 2026-04-17 07:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("letters", "0007_alter_letter_public_id"), + ] + + operations = [ + migrations.AddField( + model_name="letter", + name="notified_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/letters/models.py b/backend/letters/models.py index 83779c5..f276ac8 100644 --- a/backend/letters/models.py +++ b/backend/letters/models.py @@ -28,6 +28,7 @@ class Letter(models.Model): sealed_at = models.DateTimeField(null=True, blank=True) opened_at = models.DateTimeField(null=True, blank=True) burned_at = models.DateTimeField(null=True, blank=True) + notified_at = models.DateTimeField(null=True, blank=True) encrypted_dek = models.TextField(null=True, blank=True) def clean(self): diff --git a/backend/letters/tasks.py b/backend/letters/tasks.py new file mode 100644 index 0000000..2d233f3 --- /dev/null +++ b/backend/letters/tasks.py @@ -0,0 +1,38 @@ +import logging +from datetime import UTC, datetime + +from django.core.mail import send_mail + +from config import settings +from letters.models import Letter + +logger = logging.getLogger(__name__) + + +def get_vault_letters_to_notify(): + """ + Identifies the vault letters that have been recently unlocked and not notified + """ + letters = Letter.objects.filter(unlock_at__lt=datetime.now(UTC), notified_at=None) + return letters + + +def notify_unlocked_letter(letter): + """ + Notifies the author of the letter via email and if successful, updates the notified_at field for the letter. + """ + author = letter.user.get_username() + try: + send_mail(subject="", message="", from_email=settings.FROM_EMAIL, recipient_list=[author], fail_silently=False) + letter.notified_at = datetime.now(UTC) + letter.save() + except Exception: + logger.exception(f"Failed to notify {author} of unlocked letter") + + +def vault_unlock_notification_polling_scheduler(): + logger.info("Starting vault_unlock_notification_polling_scheduler") + letters_to_notify = get_vault_letters_to_notify() + print("letters_to_notify", letters_to_notify) + for letter in letters_to_notify: + notify_unlocked_letter(letter) diff --git a/backend/letters/tests.py b/backend/letters/tests.py index 2be80fc..7fa5844 100644 --- a/backend/letters/tests.py +++ b/backend/letters/tests.py @@ -1,5 +1,7 @@ -from datetime import UTC +from datetime import UTC, datetime, timedelta +from unittest.mock import ANY, patch +from django.conf import settings from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.test import TestCase @@ -277,3 +279,72 @@ class LetterImageModelTest(TestCase): self.assertEqual(LetterImage.objects.count(), 1) self.letter.delete() self.assertEqual(LetterImage.objects.count(), 0) + + +class LetterTaskTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(email="task@pi-ku.app", password="password1234") + + def test_get_vault_letters_to_be_notified(self): + """ + Test that the task can successfully retrieve the letters whose unlock date is passed and haven't been notified. + """ + from letters.tasks import get_vault_letters_to_notify + + Letter.objects.create( + user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) + timedelta(seconds=1) + ) + Letter.objects.create(user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC)) + Letter.objects.create( + user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) - timedelta(seconds=1) + ) + Letter.objects.create( + user=self.user, + type="VAULT", + status="SEALED", + unlock_at=datetime.now(UTC) - timedelta(hours=1), + notified_at=datetime.now(UTC) - timedelta(minutes=59), + ) + Letter.objects.create( + user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) + timedelta(seconds=1) + ) + Letter.objects.create( + user=self.user, + type="KEPT", + status="SEALED", + ) + + unlocked_letters = get_vault_letters_to_notify() + + self.assertEqual(len(unlocked_letters), 2) + + def test_notify_unlocked_letter(self): + """ + Test that the task successfully notifies the user via email and updates the database field. + """ + from letters.tasks import notify_unlocked_letter + + letter_to_notify1 = Letter.objects.create( + user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None + ) + with patch("tasks.send_mail") as mock_send_mail: + notify_unlocked_letter(letter_to_notify1) + + mock_send_mail.assert_called_with( + subject=ANY, + message=ANY, + from_email=settings.FROM_EMAIL, + recipient_list=[self.user.email], + fail_silently=False, + ) + self.assertIsNotNone(letter_to_notify1.notified_at) + + letter_to_notify2 = Letter.objects.create( + user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None + ) + with patch("tasks.send_mail") as mock_send_mail: + mock_send_mail.side_effect = Exception() + + notify_unlocked_letter(letter_to_notify2) + + self.assertIsNone(letter_to_notify2.notified_at)