diff --git a/alert_system/admin.py b/alert_system/admin.py index d5588e715..281387225 100644 --- a/alert_system/admin.py +++ b/alert_system/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Connector, ExtractionItem, LoadItem +from .models import AlertEmailLog, AlertEmailThread, Connector, ExtractionItem, LoadItem @admin.register(Connector) @@ -50,3 +50,45 @@ class LoadItemAdmin(admin.ModelAdmin): "id", "correlation_id", ) + + +@admin.register(AlertEmailThread) +class AlertEmailThreadAdmin(admin.ModelAdmin): + list_display = ( + "user", + "correlation_id", + "root_email_message_id", + ) + search_fields = ( + "correlation_id", + "root_email_message_id", + "user__username", + ) + list_select_related = ("user",) + autocomplete_fields = ("user",) + + +@admin.register(AlertEmailLog) +class AlertEmailLogAdmin(admin.ModelAdmin): + list_display = ( + "id", + "message_id", + "status", + ) + list_select_related = ( + "user", + "subscription", + "item", + "thread", + ) + search_fields = ( + "user__username", + "message_id", + ) + autocomplete_fields = ( + "user", + "subscription", + "item", + "thread", + ) + list_filter = ("status",) diff --git a/alert_system/dev_views.py b/alert_system/dev_views.py new file mode 100644 index 000000000..4a73bb2ed --- /dev/null +++ b/alert_system/dev_views.py @@ -0,0 +1,75 @@ +from django.http import HttpResponse +from django.template import loader +from rest_framework import permissions +from rest_framework.views import APIView + + +class AlertEmailPreview(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + type_param = request.GET.get("type") + + template_map = { + "alert": "email/alert_system/alert_notification.html", + "alert_reply": "email/alert_system/alert_notification_reply.html", + } + + if type_param not in template_map: + valid_values = ", ".join(template_map.keys()) + return HttpResponse( + f"Invalid 'type' parameter. Please use one of the following values: {valid_values}.", + ) + context_map = { + "alert": { + "user_name": "Test User", + "event_title": "Test Title", + "event_description": "This is a test description for the alert email.", + "start_datetime": "2025-11-28 01:00:00", + "end_datetime": "2025-11-28 01:00:00", + "country_name": [ + "Nepal", + ], + "total_people_exposed": 1200, + "total_buildings_exposed": 150, + "hazard_types": "Flood", + "related_montandon_events": [ + { + "event_title": "Related Event 1", + "total_people_exposed": 100, + "total_buildings_exposed": 300, + "start_datetime": "2025-11-28 01:00:00", + "end_datetime": "2025-11-28 01:00:00", + }, + { + "event_title": "Related Event 2", + "total_people_exposed": 200, + "total_buildings_exposed": 500, + "start_datetime": "2025-11-28 01:00:00", + "end_datetime": "2025-11-28 01:00:00", + }, + ], + "related_go_events": [ + "go-event-uuid-1", + "go-event-uuid-2", + ], + }, + "alert_reply": { + "event_title": "Test Title", + "event_description": "This is a test description for the alert email.", + "start_datetime": "2025-11-28 01:00:00", + "end_datetime": "2025-11-28 01:00:00", + "country_name": [ + "Nepal", + ], + "total_people_exposed": 1200, + "total_buildings_exposed": 150, + }, + } + + context = context_map.get(type_param) + if context is None: + return HttpResponse("No context found for the email preview.") + template_file = template_map[type_param] + template = loader.get_template(template_file) + return HttpResponse(template.render(context, request)) diff --git a/alert_system/factories.py b/alert_system/factories.py new file mode 100644 index 000000000..ca9b956ba --- /dev/null +++ b/alert_system/factories.py @@ -0,0 +1,39 @@ +import factory + +from alert_system.models import AlertEmailLog, AlertEmailThread, Connector, LoadItem + + +class LoadItemFactory(factory.django.DjangoModelFactory): + class Meta: + model = LoadItem + + @factory.post_generation + def related_montandon_events(self, create, extracted, **kwargs): + if not create: + return + if extracted: + for event in extracted: + self.related_montandon_events.add(event) + + @factory.post_generation + def related_go_events(self, create, extracted, **kwargs): + if not create: + return + if extracted: + for event in extracted: + self.related_go_events.add(event) + + +class AlertEmailLogFactory(factory.django.DjangoModelFactory): + class Meta: + model = AlertEmailLog + + +class ConnectorFactory(factory.django.DjangoModelFactory): + class Meta: + model = Connector + + +class AlertEmailThreadFactory(factory.django.DjangoModelFactory): + class Meta: + model = AlertEmailThread diff --git a/alert_system/management/commands/alert_notification.py b/alert_system/management/commands/alert_notification.py new file mode 100644 index 000000000..826b3e6c4 --- /dev/null +++ b/alert_system/management/commands/alert_notification.py @@ -0,0 +1,29 @@ +from django.core.management.base import BaseCommand + +from alert_system.models import LoadItem +from alert_system.tasks import process_email_alert + +# from sentry_sdk import monitor + + +# from main.sentry import SentryMonitor + + +class Command(BaseCommand): + help = "Send alert email notifications for eligible load items" + + # @monitor(monitor_slug=SentryMonitor.ALERT_NOTIFICATION) + def handle(self, *args, **options): + + items = LoadItem.objects.filter(item_eligible=True, is_past_event=False) + + if not items.exists(): + self.stdout.write(self.style.NOTICE("No eligible items found")) + return + + self.stdout.write(self.style.NOTICE(f"Queueing {items.count()} items for alert email notification.")) + + for item in items.iterator(): + process_email_alert.delay(load_item_id=item.id) + + self.stdout.write(self.style.SUCCESS("All alert notification email queued successfully")) diff --git a/alert_system/migrations/0002_alertemailthread_alertemaillog_and_more.py b/alert_system/migrations/0002_alertemailthread_alertemaillog_and_more.py new file mode 100644 index 000000000..1344f5417 --- /dev/null +++ b/alert_system/migrations/0002_alertemailthread_alertemaillog_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.26 on 2026-01-09 11:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0016_alertsubscription'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('alert_system', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AlertEmailThread', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('correlation_id', models.CharField(help_text='Identifier linking related LoadItems into the same email thread.', max_length=255)), + ('root_email_message_id', models.CharField(help_text='Message-ID of the first email in this thread.', max_length=255, unique=True)), + ('root_message_sent_at', models.DateTimeField(help_text='Timestamp when the root email was sent.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alert_email_threads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Email Thread', + 'verbose_name_plural': 'Email Threads', + 'ordering': ['-id'], + }, + ), + migrations.CreateModel( + name='AlertEmailLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message_id', models.CharField(help_text='Unique Message-ID of email for tracking and threading.', max_length=255, unique=True, verbose_name='Message ID')), + ('status', models.IntegerField(choices=[(100, 'Pending'), (200, 'Processing'), (300, 'Sent'), (400, 'Failed')], db_index=True, default=100, verbose_name='Email Status')), + ('email_sent_at', models.DateTimeField(blank=True, help_text='Timestamp when email was successfully sent.', null=True, verbose_name='Sent At')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_alert_load_item', to='alert_system.loaditem', verbose_name='Load Item')), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_alert_subscription', to='notifications.alertsubscription', verbose_name='Alert Subscription')), + ('thread', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='email_alert_thread', to='alert_system.alertemailthread', verbose_name='Email Thread')), + ('user', models.ForeignKey(help_text='The recipient of this alert email.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Email Alert Log', + 'verbose_name_plural': 'Email Alert Logs', + 'ordering': ['-id'], + }, + ), + migrations.AddIndex( + model_name='alertemailthread', + index=models.Index(fields=['correlation_id', 'user'], name='alert_syste_correla_2e9c7b_idx'), + ), + migrations.AddIndex( + model_name='alertemaillog', + index=models.Index(fields=['user', 'subscription', 'email_sent_at'], name='alert_syste_user_id_06a2ee_idx'), + ), + migrations.AddIndex( + model_name='alertemaillog', + index=models.Index(fields=['user', 'item', 'status'], name='alert_syste_user_id_e51594_idx'), + ), + ] diff --git a/alert_system/models.py b/alert_system/models.py index e4b2f1fcd..7b784b068 100644 --- a/alert_system/models.py +++ b/alert_system/models.py @@ -1,8 +1,10 @@ +from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ from api.models import DisasterType, Event +from notifications.models import AlertSubscription class ImpactDetailsEnum: @@ -156,7 +158,6 @@ class LoadItem(BaseItem): verbose_name=_("Event Description"), help_text=_("Description of the event"), ) - start_datetime = models.DateTimeField(null=False, blank=False, help_text="Start datetime of the event") end_datetime = models.DateTimeField(null=True, blank=False, help_text="End datetime of the event") @@ -221,3 +222,119 @@ def __str__(self): class Meta: verbose_name = _("Eligible Item") verbose_name_plural = _("Eligible Items") + + +class AlertEmailThread(models.Model): + """ + Represents a single email conversation thread for alert emails. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="alert_email_threads", + ) + + correlation_id = models.CharField( + max_length=255, + help_text=_("Identifier linking related LoadItems into the same email thread."), + ) + + root_email_message_id = models.CharField( + max_length=255, + unique=True, + help_text=_("Message-ID of the first email in this thread."), + ) + + root_message_sent_at = models.DateTimeField( + help_text=_("Timestamp when the root email was sent."), + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("Email Thread") + verbose_name_plural = _("Email Threads") + ordering = ["-id"] + indexes = [ + models.Index(fields=["correlation_id", "user"]), + ] + + def __str__(self): + return f"Thread: {self.user.get_full_name()}-{self.correlation_id}" + + +class AlertEmailLog(models.Model): + """ + Log of alert emails sent to users, tracking status, type, and threading. + """ + + class Status(models.IntegerChoices): + PENDING = 100, _("Pending") + PROCESSING = 200, _("Processing") + SENT = 300, _("Sent") + FAILED = 400, _("Failed") + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + verbose_name=_("User"), + help_text=_("The recipient of this alert email."), + ) + subscription = models.ForeignKey[AlertSubscription, AlertSubscription]( + AlertSubscription, + on_delete=models.CASCADE, + related_name="email_alert_subscription", + verbose_name=_("Alert Subscription"), + ) + + item = models.ForeignKey[LoadItem, LoadItem]( + LoadItem, + on_delete=models.CASCADE, + related_name="email_alert_load_item", + verbose_name=_("Load Item"), + ) + + message_id = models.CharField( + max_length=255, + unique=True, + verbose_name=_("Message ID"), + help_text=_("Unique Message-ID of email for tracking and threading."), + ) + + status = models.IntegerField( + choices=Status.choices, + default=Status.PENDING, + db_index=True, + verbose_name=_("Email Status"), + ) + + email_sent_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Sent At"), + help_text=_("Timestamp when email was successfully sent."), + ) + + thread = models.ForeignKey( + AlertEmailThread, + on_delete=models.CASCADE, + related_name="email_alert_thread", + null=True, + blank=True, + verbose_name=_("Email Thread"), + ) + + id: int + subscription_id: int + item_id: int + thread_id: int + + class Meta: + verbose_name = _("Email Alert Log") + verbose_name_plural = _("Email Alert Logs") + ordering = ["-id"] + indexes = [ + models.Index(fields=["user", "subscription", "email_sent_at"]), + models.Index(fields=["user", "item", "status"]), + ] diff --git a/alert_system/tasks.py b/alert_system/tasks.py index 9d44ab725..7a4388014 100644 --- a/alert_system/tasks.py +++ b/alert_system/tasks.py @@ -5,13 +5,15 @@ from celery import chain, group, shared_task from celery.exceptions import MaxRetriesExceededError from django.db import transaction -from django.db.models import Max +from django.db.models import Count, Max +from django.utils import timezone from alert_system.etl.base.extraction import PastEventExtractionClass +from alert_system.utils import get_alert_subscriptions, send_alert_email_notification from api.models import Event from .helpers import get_connector_processor, set_connector_status -from .models import Connector, LoadItem +from .models import AlertEmailLog, AlertEmailThread, Connector, LoadItem logger = logging.getLogger(__name__) @@ -170,3 +172,75 @@ def process_connector_task(connector_id): return chain( polling_task.s(connector_id), group(fetch_past_events_from_go.s(connector_id), fetch_past_events_from_monty.s()) ).apply_async() + + +@shared_task() +def process_email_alert(load_item_id: int) -> None: + load_item = LoadItem.objects.select_related("connector", "connector__dtype").filter(id=load_item_id).first() + + if not load_item: + logger.warning(f"LoadItem with ID [{load_item_id}] not found") + return + + subscriptions = get_alert_subscriptions(load_item) + if not subscriptions.exists(): + logger.info(f"No alert subscriptions matched for LoadItem ID [{load_item_id}]") + return + + today = timezone.now().date() + subscriptions_list = list(subscriptions) + user_ids = [sub.user.id for sub in subscriptions_list] + subscription_ids = [sub.id for sub in subscriptions_list] + + # Daily email counts per user + daily_counts = ( + AlertEmailLog.objects.filter( + user_id__in=user_ids, + subscription_id__in=subscription_ids, + status=AlertEmailLog.Status.SENT, + email_sent_at__date=today, + ) + .values("user_id", "subscription_id") + .annotate(sent_count=Count("id")) + ) + daily_count_map = {(item["user_id"], item["subscription_id"]): item["sent_count"] for item in daily_counts} + + # Emails already sent for this item (per user) + already_sent = set( + AlertEmailLog.objects.filter( + user_id__in=user_ids, + subscription_id__in=subscription_ids, + item_id=load_item_id, + status=AlertEmailLog.Status.SENT, + ).values_list("user_id", "subscription_id") + ) + + # Existing threads for this correlation_id + threads_qs = AlertEmailThread.objects.filter( + correlation_id=load_item.correlation_id, + user_id__in=user_ids, + ).select_related("user") + + existing_threads = {thread.user.id: thread for thread in threads_qs} + + for subscription in subscriptions: + user = subscription.user + user_id: int = user.id + subscription_id: int = subscription.id + + # Reply if this specific user has an existing thread + thread = existing_threads.get(user_id) + is_reply: bool = thread is not None + + # Skip if daily alert limit reached + sent_today: int = daily_count_map.get((user_id, subscription_id), 0) + if subscription.alert_per_day and sent_today >= subscription.alert_per_day: + logger.info(f"Daily alert limit reached for user [{user.get_full_name()}]") + continue + + # Skip duplicate emails for same item + if (user_id, subscription_id) in already_sent: + logger.info(f"Duplicate alert skipped for user [{user.get_full_name()}] " f"with LoadItem ID [{subscription_id}]") + continue + + send_alert_email_notification(load_item=load_item, user=user, subscription=subscription, thread=thread, is_reply=is_reply) diff --git a/alert_system/tests.py b/alert_system/tests.py new file mode 100644 index 000000000..6919544f1 --- /dev/null +++ b/alert_system/tests.py @@ -0,0 +1,471 @@ +from unittest import mock +from uuid import uuid4 + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from alert_system.models import AlertEmailLog, AlertEmailThread, Connector +from alert_system.tasks import process_email_alert +from api.factories.country import CountryFactory +from api.factories.disaster_type import DisasterTypeFactory +from api.factories.region import RegionFactory +from deployments.factories.user import UserFactory +from notifications.factories import AlertSubscriptionFactory +from notifications.models import AlertSubscription + +from .factories import ( + AlertEmailLogFactory, + AlertEmailThreadFactory, + ConnectorFactory, + LoadItemFactory, +) + + +class AlertEmailNotificationTestCase(TestCase): + """Comprehensive test suite for alert email notifications (both root and reply emails)""" + + def setUp(self): + self.user1 = UserFactory.create(email="testuser1@example.com") + self.user2 = UserFactory.create(email="testuser2@example.com") + + self.region = RegionFactory.create() + self.country = CountryFactory.create( + name="Nepal", + iso3="NEP", + iso="NP", + region=self.region, + ) + + self.hazard_type = DisasterTypeFactory.create(name="Flood") + + self.connector = ConnectorFactory.create( + type=Connector.ConnectorType.GDACS_FLOOD, + dtype=self.hazard_type, + status=Connector.Status.SUCCESS, + source_url="https://test.com/stac", + ) + + self.subscription1 = AlertSubscriptionFactory.create( + user=self.user1, + countries=[self.country], + hazard_types=[self.hazard_type], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, + ) + + self.subscription2 = AlertSubscriptionFactory.create( + user=self.user2, + countries=[self.country], + hazard_types=[self.hazard_type], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, + ) + + self.eligible_item = LoadItemFactory.create( + correlation_id="corr-001", + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Test Flood Event", + event_description="Initial flood event", + country_codes=["NEP"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + self.ineligible_item = LoadItemFactory.create( + connector=self.connector, + item_eligible=False, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Test Event", + country_codes=["IND"], + event_description="Should not trigger email", + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + @mock.patch("alert_system.utils.send_notification") + def test_sent_email_for_eligible_item(self, mock_send_notification): + + process_email_alert(self.eligible_item.id) + + log = AlertEmailLog.objects.get(user=self.user1, item=self.eligible_item) + thread = AlertEmailThread.objects.get(user=self.user1) + + self.assertEqual(log.user, self.user1) + self.assertEqual(log.item, self.eligible_item) + self.assertEqual(log.subscription, self.subscription1) + self.assertEqual(log.status, AlertEmailLog.Status.SENT) + self.assertIsNotNone(log.email_sent_at) + + self.assertEqual(thread.user, self.user1) + self.assertEqual(thread.correlation_id, self.eligible_item.correlation_id) + self.assertEqual(thread.root_email_message_id, log.message_id) + self.assertEqual(log.thread, thread) + + mock_send_notification.assert_called() + + @mock.patch("alert_system.utils.send_notification") + def test_sent_email_to_multiple_users(self, mock_send_notification): + + process_email_alert(self.eligible_item.id) + + logs = AlertEmailLog.objects.filter(item=self.eligible_item, status=AlertEmailLog.Status.SENT) + self.assertEqual(logs.count(), 2) + + threads = AlertEmailThread.objects.filter(correlation_id=self.eligible_item.correlation_id) + self.assertEqual(threads.count(), 2) + + self.assertEqual(mock_send_notification.call_count, 2) + + # Verify each user got their own thread + user1_log = logs.get(user=self.user1) + user2_log = logs.get(user=self.user2) + user1_thread = threads.get(user=self.user1) + user2_thread = threads.get(user=self.user2) + + self.assertEqual(user1_log.thread, user1_thread) + self.assertEqual(user2_log.thread, user2_thread) + self.assertNotEqual(user1_thread.root_email_message_id, user2_thread.root_email_message_id) + + @mock.patch("alert_system.utils.send_notification") + def test_daily_email_alert_limit(self, mock_send_notification): + + user = UserFactory.create(email="t@example.com") + country = CountryFactory.create( + name="Philippines", + iso3="PHI", + iso="PH", + region=self.region, + ) + + subscription = AlertSubscriptionFactory.create( + user=user, + countries=[country], + hazard_types=[self.hazard_type], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, + ) + + item = LoadItemFactory.create( + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Test Flood Event", + country_codes=["PHI"], + event_description="Should not trigger email", + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + for _ in range(subscription.alert_per_day): + AlertEmailLogFactory.create( + user=user, + subscription=subscription, + item=item, + status=AlertEmailLog.Status.SENT, + email_sent_at=timezone.now(), + message_id=uuid4(), + ) + + sent_before = AlertEmailLog.objects.filter( + user=user, + subscription=subscription, + status=AlertEmailLog.Status.SENT, + ).count() + + process_email_alert(item.id) + + sent_after = AlertEmailLog.objects.filter( + user=user, + subscription=subscription, + status=AlertEmailLog.Status.SENT, + ).count() + + self.assertEqual(sent_before, sent_after) + mock_send_notification.assert_not_called() + + # Test Reply emails + + @mock.patch("alert_system.utils.send_notification") + def test_reply_email_for_existing_thread(self, mock_send_notification): + user = UserFactory.create() + country = CountryFactory.create( + name="China", + iso3="CHN", + iso="CH", + region=self.region, + ) + + subscription = AlertSubscriptionFactory.create( + user=user, + countries=[country], + hazard_types=[self.hazard_type], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, + ) + + initial_item = LoadItemFactory.create( + correlation_id=str(uuid4()), + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Initial Flood", + country_codes=["CHN"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, + ) + + thread = AlertEmailThreadFactory.create( + user=user, + correlation_id=initial_item.correlation_id, + root_email_message_id=str(uuid4()), + root_message_sent_at=timezone.now(), + ) + + AlertEmailLogFactory.create( + user=user, + subscription=subscription, + item=initial_item, + thread=thread, + message_id=str(uuid4()), + status=AlertEmailLog.Status.SENT, + email_sent_at=timezone.now(), + ) + + update_item = LoadItemFactory.create( + correlation_id=initial_item.correlation_id, + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Flood Update", + country_codes=["CHN"], + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "unit-test"}, + ) + + process_email_alert(update_item.id) + + reply_log = AlertEmailLog.objects.get(user=user, item=update_item) + self.assertEqual(reply_log.status, AlertEmailLog.Status.SENT) + self.assertIsNotNone(reply_log.email_sent_at) + + mock_send_notification.assert_called_once() + + threads = AlertEmailThread.objects.filter(correlation_id=initial_item.correlation_id) + self.assertEqual(threads.count(), 1) + + @mock.patch("alert_system.utils.send_notification") + def test_reply_email_to_multiple_users(self, mock_send_notification): + + correlation_id = str(uuid4()) + + # Create initial item + initial_item = LoadItemFactory.create( + correlation_id=correlation_id, + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Initial Event", + country_codes=["NEP"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, + ) + + # Create threads for both users + thread1 = AlertEmailThreadFactory.create( + user=self.user1, + correlation_id=correlation_id, + root_email_message_id="message-id-1", + root_message_sent_at=timezone.now(), + ) + + thread2 = AlertEmailThreadFactory.create( + user=self.user2, + correlation_id=correlation_id, + root_email_message_id="message-id-2", + root_message_sent_at=timezone.now(), + ) + + AlertEmailLogFactory.create( + user=self.user1, + subscription=self.subscription1, + item=initial_item, + thread=thread1, + message_id="message-id-1", + status=AlertEmailLog.Status.SENT, + email_sent_at=timezone.now(), + ) + + AlertEmailLogFactory.create( + user=self.user2, + subscription=self.subscription2, + item=initial_item, + thread=thread2, + message_id="message-id-2", + status=AlertEmailLog.Status.SENT, + email_sent_at=timezone.now(), + ) + + related_item = LoadItemFactory.create( + correlation_id=correlation_id, + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Update Event", + country_codes=["NEP"], + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "test"}, + ) + + process_email_alert(related_item.id) + + replies = AlertEmailLog.objects.filter(item=related_item, status=AlertEmailLog.Status.SENT) + self.assertEqual(replies.count(), 2) + self.assertEqual(mock_send_notification.call_count, 2) + + reply_user1 = replies.get(user=self.user1) + reply_user2 = replies.get(user=self.user2) + self.assertNotEqual(reply_user1.message_id, reply_user2.message_id) + + @mock.patch("alert_system.utils.send_notification") + def test_duplicate_reply(self, mock_send_notification): + + correlation_id = str(uuid4()) + + user = UserFactory.create() + country = CountryFactory.create( + name="Pakistan", + iso3="PAK", + region=self.region, + ) + subscription = AlertSubscriptionFactory.create( + user=user, + countries=[country], + hazard_types=[self.hazard_type], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, + ) + + LoadItemFactory.create( + correlation_id=correlation_id, + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Initial", + country_codes=["PAK"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, + ) + + thread = AlertEmailThreadFactory.create( + user=user, + correlation_id=correlation_id, + root_email_message_id="root-123", + root_message_sent_at=timezone.now(), + ) + + update_item = LoadItemFactory.create( + correlation_id=correlation_id, + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Update", + country_codes=["PAK"], + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "unit-test"}, + ) + + # Existing reply for user1 + AlertEmailLogFactory.create( + user=user, + subscription=subscription, + item=update_item, + thread=thread, + message_id="reply-123", + status=AlertEmailLog.Status.SENT, + email_sent_at=timezone.now(), + ) + + user2 = UserFactory.create() + AlertSubscriptionFactory.create( + user=user2, + countries=subscription.countries.all(), + regions=subscription.regions.all(), + hazard_types=subscription.hazard_types.all(), + alert_per_day=subscription.alert_per_day, + ) + + process_email_alert(update_item.id) + + # user should NOT get a duplicate + replies_user1 = AlertEmailLog.objects.filter(user=user, item=update_item) + self.assertEqual(replies_user1.count(), 1) + + # user2 should get an email + replies_user2 = AlertEmailLog.objects.filter(user=user2, item=update_item) + self.assertEqual(replies_user2.count(), 1) + + mock_send_notification.assert_called_once() + + # Test command trigger + @mock.patch("alert_system.tasks.process_email_alert.delay") + def test_command_triggers_task_for_eligible_items(self, mock_task_delay): + """Test that management command queues eligible items""" + call_command("alert_notification") + + mock_task_delay.assert_called_once_with(load_item_id=self.eligible_item.id) + + @mock.patch("alert_system.tasks.process_email_alert.delay") + def test_command_for_ineligible_items(self, mock_task_delay): + + # Delete eligible item + self.eligible_item.delete() + + call_command("alert_notification") + + mock_task_delay.assert_not_called() + + @mock.patch("alert_system.tasks.process_email_alert.delay") + def test_command_trigger_for_past_events(self, mock_task_delay): + + self.eligible_item.is_past_event = True + self.eligible_item.save() + + call_command("alert_notification") + + mock_task_delay.assert_not_called() + + @mock.patch("alert_system.utils.send_notification") + def test_email_send_failed(self, mock_send_notification): + + mock_send_notification.side_effect = Exception("Email service error") + + process_email_alert(self.eligible_item.id) + + log = AlertEmailLog.objects.get(user=self.user1, item=self.eligible_item) + self.assertEqual(log.status, AlertEmailLog.Status.FAILED) + self.assertIsNone(log.email_sent_at) diff --git a/alert_system/utils.py b/alert_system/utils.py new file mode 100644 index 000000000..adb901c77 --- /dev/null +++ b/alert_system/utils.py @@ -0,0 +1,123 @@ +import logging +import uuid +from typing import Optional + +from django.conf import settings +from django.contrib.auth.models import User +from django.db.models import Q +from django.template.loader import render_to_string +from django.utils import timezone + +from alert_system.models import AlertEmailLog, AlertEmailThread, LoadItem +from api.models import Country +from notifications.models import AlertSubscription +from notifications.notification import send_notification + +logger = logging.getLogger(__name__) + + +def get_alert_email_context(load_item: LoadItem, user: User): + + country_names = [] + + if load_item.country_codes: + country_names = list(Country.objects.filter(iso3__in=load_item.country_codes).values_list("name", flat=True)) + email_context = { + "user_name": user.get_full_name(), + "event_title": load_item.event_title, + "event_description": load_item.event_description, + "country_name": country_names, + "total_people_exposed": load_item.total_people_exposed, + "total_buildings_exposed": load_item.total_buildings_exposed, + "hazard_types": load_item.connector.dtype, + "related_go_events": load_item.related_go_events.all(), + "related_montandon_events": load_item.related_montandon_events.filter(item_eligible=True).order_by( + "-total_people_exposed" + ), + "frontend_url": settings.GO_WEB_URL, + "start_datetime": load_item.start_datetime, + "end_datetime": load_item.end_datetime, + } + return email_context + + +def get_alert_subscriptions(load_item: LoadItem): + + regions = Country.objects.filter(iso3__in=load_item.country_codes).values_list("region_id", flat=True) + + return ( + AlertSubscription.objects.filter(hazard_types=load_item.connector.dtype) + .filter(Q(countries__iso3__in=load_item.country_codes) | Q(regions__in=regions)) + .select_related("user") + .distinct() + ) + + +def send_alert_email_notification( + load_item: LoadItem, + user: User, + subscription: AlertSubscription, + thread: Optional[AlertEmailThread], + is_reply: bool = False, +) -> None: + """Helper function to send email and create log entry""" + message_id: str = str(uuid.uuid4()) + + email_log = AlertEmailLog.objects.create( + user=user, + subscription=subscription, + item=load_item, + status=AlertEmailLog.Status.PROCESSING, + message_id=message_id, + thread=thread, + ) + + try: + if is_reply: + subject = f"Re: Hazard Alert: {load_item.event_title}" + template = "email/alert_system/alert_notification_reply.html" + email_type = "Alert Email Notification Reply" + in_reply_to = thread.root_email_message_id + else: + subject = f"New Hazard Alert: {load_item.event_title}" + template = "email/alert_system/alert_notification.html" + email_type = "Alert Email Notification" + in_reply_to = None + + email_context = get_alert_email_context(load_item, user) + email_body = render_to_string(template, email_context) + + send_notification( + subject=subject, + recipients=user.email, + message_id=message_id, + in_reply_to=in_reply_to, + html=email_body, + mailtype=email_type, + ) + + email_log.status = AlertEmailLog.Status.SENT + email_log.email_sent_at = timezone.now() + email_log.save(update_fields=["status", "email_sent_at"]) + + # Create thread for initial emails + if not is_reply: + thread = AlertEmailThread.objects.create( + user=user, + correlation_id=load_item.correlation_id, + root_email_message_id=message_id, + root_message_sent_at=timezone.now(), + ) + email_log.thread = thread + email_log.save(update_fields=["thread"]) + logger.info( + f"Alert Email thread created for user [{user.get_full_name()}] " + f"with correlation_id [{load_item.correlation_id}]" + ) + + logger.info(f"Alert email sent to [{user.get_full_name()}] for LoadItem ID [{load_item.id}]") + + except Exception: + email_log.status = AlertEmailLog.Status.FAILED + email_log.save(update_fields=["status"]) + logger.warning(f"Alert email failed for [{user.get_full_name()}] LoadItem ID [{load_item.id}]", exc_info=True) diff --git a/api/test_views.py b/api/test_views.py index 75c56c4fe..a116754a0 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -1,3 +1,4 @@ +import datetime import re import uuid from unittest.mock import patch @@ -868,73 +869,81 @@ class AppealTest(APITestCase): fixtures = ["DisasterTypes"] def test_appeal_key_figure(self): - region1 = models.Region.objects.create(name=1) - region2 = models.Region.objects.create(name=2) - country1 = models.Country.objects.create(name="Nepal", iso3="NPL", region=region1) - country2 = models.Country.objects.create(name="India", iso3="IND", region=region2) - dtype1 = models.DisasterType.objects.get(pk=1) - dtype2 = models.DisasterType.objects.get(pk=2) - event1 = EventFactory.create( - name="test1", - dtype=dtype1, - ) - event2 = EventFactory.create(name="test0", dtype=dtype1, num_affected=10000, countries=[country1]) - event3 = EventFactory.create(name="test2", dtype=dtype2, num_affected=99999, countries=[country2]) - AppealFactory.create( - event=event1, - dtype=dtype1, - num_beneficiaries=9000, - amount_requested=10000, - amount_funded=1899999, - code=12, - start_date="2024-1-1", - end_date="2024-1-1", - atype=AppealType.APPEAL, - country=country1, - ) - AppealFactory.create( - event=event2, - dtype=dtype2, - num_beneficiaries=90023, - amount_requested=100440, - amount_funded=12299999, - code=123, - start_date="2024-2-2", - end_date="2024-2-2", - atype=AppealType.DREF, - country=country1, - ) - AppealFactory.create( - event=event3, - dtype=dtype2, - num_beneficiaries=91000, - amount_requested=10000888, - amount_funded=678888, - code=1234, - start_date="2024-3-3", - end_date="2024-3-3", - atype=AppealType.APPEAL, - country=country1, - ) - AppealFactory.create( - event=event3, - dtype=dtype2, - num_beneficiaries=91000, - amount_requested=10000888, - amount_funded=678888, - code=12345, - start_date="2024-4-4", - end_date="2024-4-4", - atype=AppealType.APPEAL, - country=country1, - ) - url = f"/api/v2/country/{country1.id}/figure/" - self.client.force_authenticate(self.user) - response = self.client.get(url) - self.assert_200(response) - self.assertIsNotNone(response.json()) - self.assertEqual(response.data["active_drefs"], 1) - self.assertEqual(response.data["active_appeals"], 3) + creation_time = datetime.datetime(2023, 1, 5, 17, 4, 42, tzinfo=datetime.timezone.utc) + view_time = datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + + with patch("django.utils.timezone.now") as mock_now: + mock_now.return_value = creation_time + region1 = models.Region.objects.create(name=1) + region2 = models.Region.objects.create(name=2) + country1 = models.Country.objects.create(name="Nepal", iso3="NPL", region=region1) + country2 = models.Country.objects.create(name="India", iso3="IND", region=region2) + dtype1 = models.DisasterType.objects.get(pk=1) + dtype2 = models.DisasterType.objects.get(pk=2) + event1 = EventFactory.create( + name="test1", + dtype=dtype1, + ) + event2 = EventFactory.create(name="test0", dtype=dtype1, num_affected=10000, countries=[country1]) + event3 = EventFactory.create(name="test2", dtype=dtype2, num_affected=99999, countries=[country2]) + AppealFactory.create( + event=event1, + dtype=dtype1, + num_beneficiaries=9000, + amount_requested=10000, + amount_funded=1899999, + code=12, + start_date="2024-1-1", + end_date="2024-1-1", + atype=AppealType.APPEAL, + country=country1, + ) + AppealFactory.create( + event=event2, + dtype=dtype2, + num_beneficiaries=90023, + amount_requested=100440, + amount_funded=12299999, + code=123, + start_date="2024-2-2", + end_date="2024-2-2", + atype=AppealType.DREF, + country=country1, + ) + AppealFactory.create( + event=event3, + dtype=dtype2, + num_beneficiaries=91000, + amount_requested=10000888, + amount_funded=678888, + code=1234, + start_date="2024-3-3", + end_date="2024-3-3", + atype=AppealType.APPEAL, + country=country1, + ) + AppealFactory.create( + event=event3, + dtype=dtype2, + num_beneficiaries=91000, + amount_requested=10000888, + amount_funded=678888, + code=12345, + start_date="2024-4-4", + end_date="2024-4-4", + atype=AppealType.APPEAL, + country=country1, + ) + + mock_now.return_value = view_time + url = f"/api/v2/country/{country1.id}/figure/" + self.client.force_authenticate(self.user) + response = self.client.get(url) + + self.assert_200(response) + self.assertIsNotNone(response.json()) + self.assertEqual(response.data["active_drefs"], 1) + self.assertEqual(response.data["active_appeals"], 3) class RegionSnippetVisibilityTest(APITestCase): diff --git a/assets b/assets index aeda366d7..f4d71cf7c 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit aeda366d7d172e5ed7eca71665937f2bc4fb0ec6 +Subproject commit f4d71cf7c94acc122ce8cd0c90234da2700e8d14 diff --git a/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 3249bc39f..58c19d22f 100644 --- a/deploy/helm/ifrcgo-helm/values.yaml +++ b/deploy/helm/ifrcgo-helm/values.yaml @@ -277,14 +277,14 @@ cronjobs: - command: 'notify_validators' schedule: '0 0 * * *' # Schedule time yet to be decided - - command: 'poll_gdacs_cyclone' - schedule: '0 0 * * 0' - - command: 'poll_gdacs_flood' - schedule: '0 0 * * 0' - - command: 'poll_usgs_earthquake' - schedule: '0 0 * * 0' - # - command: 'notify_validators' - # schedule: '0 0 * * *' + # - command: 'poll_gdacs_cyclone' + # schedule: '0 0 * * 0' + # - command: 'poll_gdacs_flood' + # schedule: '0 0 * * 0' + # - command: 'poll_usgs_earthquake' + # schedule: '0 0 * * 0' + # - command: 'alert_notification' + # schedule: '0 */2 * * *' # https://github.com/jazzband/django-oauth-toolkit/blob/master/docs/management_commands.rst#cleartokens - command: 'oauth_cleartokens' schedule: '0 1 * * *' diff --git a/main/sentry.py b/main/sentry.py index d2596272b..e9d7ee037 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -129,10 +129,11 @@ class SentryMonitor(models.TextChoices): INGEST_NS_INITIATIVES = "ingest_ns_initiatives", "0 0 * * 0" INGEST_ICRC = "ingest_icrc", "0 3 * * 0" NOTIFY_VALIDATORS = "notify_validators", "0 0 * * *" - POLL_USGS_EARTHQUAKE = "poll_usgs_earthquake", "0 0 * * 0" - POLL_GDACS_FLOOD = "poll_gdacs_flood", "0 0 * * 0" - POLL_GDACS_CYCLONE = "poll_gdacs_cyclone", "0 0 * * 0" + # POLL_USGS_EARTHQUAKE = "poll_usgs_earthquake", "0 0 * * 0" + # POLL_GDACS_FLOOD = "poll_gdacs_flood", "0 0 * * 0" + # POLL_GDACS_CYCLONE = "poll_gdacs_cyclone", "0 0 * * 0" OAUTH_CLEARTOKENS = "oauth_cleartokens", "0 1 * * *" + # ALERT_NOTIFICATION = "alert_notification", "0 */2 * * *" @staticmethod def load_cron_data() -> typing.List[typing.Tuple[str, str]]: diff --git a/main/urls.py b/main/urls.py index 7e568499a..d6086151d 100644 --- a/main/urls.py +++ b/main/urls.py @@ -21,6 +21,7 @@ # DRF routes from rest_framework import routers +from alert_system.dev_views import AlertEmailPreview from api import drf_views as api_views from api.admin_reports import UsersPerPermissionViewSet from api.views import ( @@ -280,6 +281,7 @@ # For django versions before 2.0: # url(r'^__debug__/', include(debug_toolbar.urls)), url(r"^dev/email-preview/local-units/", LocalUnitsEmailPreview.as_view()), + url(r"^dev/email-preview/alert-system/", AlertEmailPreview.as_view()), ] + urlpatterns + static.static( diff --git a/notifications/admin.py b/notifications/admin.py index 4b68e73ac..95ec89b14 100644 --- a/notifications/admin.py +++ b/notifications/admin.py @@ -68,16 +68,11 @@ def has_delete_permission(self, request, obj=None): admin.site.register(models.SurgeAlert, SurgeAlertAdmin) -@admin.register(models.HazardType) -class AlertTypeAdmin(admin.ModelAdmin): - list_display = ("type",) - search_fields = ("type",) - - @admin.register(models.AlertSubscription) class AlertSubscriptionAdmin(admin.ModelAdmin): list_select_related = True list_display = ("user", "created_at", "alert_per_day") + search_fields = ("user__username", "user__email") autocomplete_fields = ("user", "regions", "countries", "hazard_types") def get_queryset(self, request): diff --git a/notifications/enums.py b/notifications/enums.py index b05e144c2..3becd8005 100644 --- a/notifications/enums.py +++ b/notifications/enums.py @@ -3,6 +3,5 @@ enum_register = { "surge_alert_status": models.SurgeAlertStatus, "alert_source": models.AlertSubscription.AlertSource, - "hazard_type": models.HazardType.Type, "alert_per_day": models.AlertSubscription.AlertPerDay, } diff --git a/notifications/factories.py b/notifications/factories.py index 023bbb441..7325c5f4b 100644 --- a/notifications/factories.py +++ b/notifications/factories.py @@ -3,7 +3,7 @@ from deployments.factories.user import UserFactory -from .models import AlertSubscription, HazardType, SurgeAlert, SurgeAlertStatus +from .models import AlertSubscription, SurgeAlert, SurgeAlertStatus class SurgeAlertFactory(factory.django.DjangoModelFactory): @@ -54,8 +54,3 @@ def hazard_types(self, create, extracted, **kwargs): if extracted: for alert_type in extracted: self.hazard_types.add(alert_type) - - -class HazardTypeFactory(factory.django.DjangoModelFactory): - class Meta: - model = HazardType diff --git a/notifications/filter_set.py b/notifications/filter_set.py index a40e760e7..ab9fcabdd 100644 --- a/notifications/filter_set.py +++ b/notifications/filter_set.py @@ -8,7 +8,7 @@ class AlertSubscriptionFilterSet(filters.FilterSet): country = filters.ModelMultipleChoiceFilter(field_name="countries", queryset=Country.objects.all()) region = filters.ModelMultipleChoiceFilter(field_name="regions", queryset=Region.objects.all()) alert_source = filters.NumberFilter(field_name="alert_source", label="Alert Source") - hazard_type = filters.NumberFilter(field_name="hazard_types__type", label="Hazard Type") + hazard_type = filters.CharFilter(field_name="hazard_types__name", label="Hazard Type") alert_per_day = filters.ChoiceFilter(choices=AlertSubscription.AlertPerDay.choices, label="Alert Per Day") class Meta: diff --git a/notifications/migrations/0016_hazardtype_alertsubscription.py b/notifications/migrations/0016_alertsubscription.py similarity index 65% rename from notifications/migrations/0016_hazardtype_alertsubscription.py rename to notifications/migrations/0016_alertsubscription.py index 59a58553d..84402ed42 100644 --- a/notifications/migrations/0016_hazardtype_alertsubscription.py +++ b/notifications/migrations/0016_alertsubscription.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-12-03 16:28 +# Generated by Django 4.2.26 on 2026-01-02 17:07 from django.conf import settings from django.db import migrations, models @@ -8,33 +8,22 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0226_nsdinitiativescategory_and_more'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0226_nsdinitiativescategory_and_more'), ('notifications', '0015_rename_molnix_status_surgealert_molnix_status_old'), ] operations = [ - migrations.CreateModel( - name='HazardType', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.IntegerField(choices=[(100, 'Earthquake'), (200, 'Flood'), (300, 'Cyclone')], unique=True, verbose_name='Hazard Type')), - ], - options={ - 'verbose_name': 'Hazard Type', - 'verbose_name_plural': 'Hazard Types', - }, - ), migrations.CreateModel( name='AlertSubscription', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('alert_source', models.IntegerField(choices=[(100, 'Montandon')], default=100, verbose_name='Alert Source')), - ('alert_per_day', models.IntegerField(choices=[(100, 'Five'), (200, 'Ten'), (300, 'Twenty'), (400, 'Fifty'), (500, 'Unlimited')], default=100, help_text='Maximum number of alerts sent to the user per day.', verbose_name='Alerts Per Day')), + ('alert_per_day', models.IntegerField(blank=True, choices=[(5, 'Five'), (10, 'Ten'), (20, 'Twenty'), (50, 'Fifty')], help_text='Maximum number of alerts sent to the user per day. Leave empty to allow unlimited alerts.', null=True, verbose_name='Alerts Per Day')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), ('countries', models.ManyToManyField(related_name='alert_subscriptions_countries', to='api.country', verbose_name='Countries')), - ('hazard_types', models.ManyToManyField(help_text='Types of hazards the user is subscribed to.', related_name='alert_subscriptions_hazard_types', to='notifications.hazardtype', verbose_name='Hazard Types')), + ('hazard_types', models.ManyToManyField(help_text='Types of hazards the user is subscribed to.', related_name='alert_subscriptions_hazard_types', to='api.disastertype', verbose_name='Hazard Types')), ('regions', models.ManyToManyField(blank=True, related_name='alert_subscriptions_regions', to='api.region', verbose_name='Regions')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alert_subscriptions_user', to=settings.AUTH_USER_MODEL, verbose_name='User')), ], diff --git a/notifications/models.py b/notifications/models.py index e425b44e9..7f63f807a 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -322,28 +322,6 @@ class NotificationGUID(models.Model): to_list = models.TextField(null=True, blank=True) -class HazardType(models.Model): - """Model representing a hazard category.""" - - class Type(models.IntegerChoices): - EARTHQUAKE = 100, _("Earthquake") - FLOOD = 200, _("Flood") - CYCLONE = 300, _("Cyclone") - - type = models.IntegerField( - choices=Type.choices, - unique=True, - verbose_name=_("Hazard Type"), - ) - - class Meta: - verbose_name = _("Hazard Type") - verbose_name_plural = _("Hazard Types") - - def __str__(self): - return self.get_type_display() - - class AlertSubscription(models.Model): class AlertSource(models.IntegerChoices): MONTANDON = 100, _("Montandon") @@ -352,21 +330,18 @@ class AlertSource(models.IntegerChoices): class AlertPerDay(models.IntegerChoices): """Enum representing the maximum number of alerts per day.""" - FIVE = 100, _("Five") + FIVE = 5, _("Five") """Receive up to 5 alerts per day.""" - TEN = 200, _("Ten") + TEN = 10, _("Ten") """Receive up to 10 alerts per day.""" - TWENTY = 300, _("Twenty") + TWENTY = 20, _("Twenty") """Receive up to 20 alerts per day.""" - FIFTY = 400, _("Fifty") + FIFTY = 50, _("Fifty") """Receive up to 50 alerts per day.""" - UNLIMITED = 500, _("Unlimited") - """No daily alert limit.""" - user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_("User"), @@ -391,16 +366,17 @@ class AlertPerDay(models.IntegerChoices): ) hazard_types = models.ManyToManyField( - HazardType, + DisasterType, related_name="alert_subscriptions_hazard_types", verbose_name=_("Hazard Types"), help_text="Types of hazards the user is subscribed to.", ) alert_per_day = models.IntegerField( choices=AlertPerDay.choices, - default=AlertPerDay.FIVE, + blank=True, + null=True, verbose_name=_("Alerts Per Day"), - help_text="Maximum number of alerts sent to the user per day.", + help_text="Maximum number of alerts sent to the user per day. Leave empty to allow unlimited alerts.", ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) @@ -413,4 +389,4 @@ class Meta: verbose_name_plural = _("Alert Subscriptions") def __str__(self): - return f"Alert subscription for {self.user.get_full_name()}" + return f"Alert subscription of {self.user.get_full_name()}" diff --git a/notifications/notification.py b/notifications/notification.py index 2e48d1e8f..f05edca88 100644 --- a/notifications/notification.py +++ b/notifications/notification.py @@ -56,13 +56,19 @@ def run(self): CronJob.sync_cron(cron_rec) -def construct_msg(subject, html): +def construct_msg(subject, html, message_id=None, in_reply_to=None): msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = settings.EMAIL_USER.upper() msg["To"] = "no-reply@ifrc.org" + if message_id: + msg["Message-ID"] = message_id + + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + text_body = MIMEText(strip_tags(html), "plain") html_body = MIMEText(html, "html") @@ -72,7 +78,7 @@ def construct_msg(subject, html): return msg -def send_notification(subject, recipients, html, mailtype="", files=None): +def send_notification(subject, recipients, html, message_id=None, in_reply_to=None, mailtype="", files=None): """Generic email sending method, handly only HTML emails currently""" if not settings.EMAIL_USER or not settings.EMAIL_API_ENDPOINT: logger.warning("Cannot send notifications.\n" "No username and/or API endpoint set as environment variables.") @@ -88,7 +94,12 @@ def send_notification(subject, recipients, html, mailtype="", files=None): if settings.FORCE_USE_SMTP: logger.info("Forcing SMPT usage for sending emails.") - msg = construct_msg(subject, html) + msg = construct_msg( + subject=subject, + html=html, + message_id=message_id, + in_reply_to=in_reply_to, + ) SendMail(recipients, msg).start() return @@ -139,6 +150,12 @@ def send_notification(subject, recipients, html, mailtype="", files=None): "TemplateName": "", "TemplateLanguage": "", } + if in_reply_to: + payload["ReplyToAsBase64"] = str(base64.b64encode(in_reply_to.encode("utf-8")), "utf-8") + + if message_id: + payload["MessageIdAsBase64"] = str(base64.b64encode(message_id.encode("utf-8")), "utf-8") + if len(to_addresses) == 1: payload["ToAsBase64"] = payload["BccAsBase64"] # if 1 addressee, no BCC anonimization needed. payload["BccAsBase64"] = "" @@ -167,6 +184,11 @@ def send_notification(subject, recipients, html, mailtype="", files=None): ) # Try sending with Python smtplib, if reaching the API fails logger.warning(f"Authorization/authentication failed ({res.status_code}) to the e-mail sender API.") - msg = construct_msg(subject, html) + msg = construct_msg( + subject=subject, + html=html, + message_id=message_id, + in_reply_to=in_reply_to, + ) SendMail(to_addresses, msg).start() return res.text diff --git a/notifications/serializers.py b/notifications/serializers.py index 5889a1233..738c4b4b7 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from api.serializers import ( + DisasterTypeSerializer, MiniCountrySerializer, MiniEventSerializer, MiniRegionSerialzier, @@ -10,7 +11,7 @@ from deployments.serializers import MolnixTagSerializer from lang.serializers import ModelSerializer -from .models import AlertSubscription, HazardType, Subscription, SurgeAlert +from .models import AlertSubscription, Subscription, SurgeAlert class SurgeAlertSerializer(ModelSerializer): @@ -262,24 +263,11 @@ class Meta: ) -class HazardTypeSerializer(ModelSerializer): - - type_display = serializers.CharField(source="get_type_display", read_only=True) - - class Meta: - model = HazardType - fields = ( - "id", - "type", - "type_display", - ) - - class AlertSubscriptionSerialize(ModelSerializer): user_detail = UserNameSerializer(source="user", read_only=True) countries_detail = MiniCountrySerializer(source="countries", many=True, read_only=True) regions_detail = MiniRegionSerialzier(source="regions", many=True, read_only=True) - hazard_types_detail = HazardTypeSerializer(source="hazard_types", many=True, read_only=True) + hazard_types_detail = DisasterTypeSerializer(source="hazard_types", many=True, read_only=True) alert_per_day_display = serializers.CharField(source="get_alert_per_day_display", read_only=True) class Meta: diff --git a/notifications/templates/email/alert_system/alert_notification.html b/notifications/templates/email/alert_system/alert_notification.html new file mode 100644 index 000000000..6e05d9fa7 --- /dev/null +++ b/notifications/templates/email/alert_system/alert_notification.html @@ -0,0 +1,69 @@ +{% include "design/head3.html" %} + + + + + +
+

Dear {{ user_name }},

+

+ A new alert, {{ event_title }}, has been observed in + {% for country in country_name %} + {{ country }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% if start_datetime %} + at {{ start_datetime }} + {% endif %} +

+

Key Figures

+
    +
  • Total People Exposed: {{ total_people_exposed }}
  • +
  • Total Buildings Exposed: {{ total_buildings_exposed }}
  • +
  • Hazard Type: {{ hazard_types }}
  • +
+ +

Similar Past Events

+ {% if related_montandon_events %} +
    + {% for event in related_montandon_events|slice:":4" %} +
  • + {{ event.event_title }} +
      +
    • Total People Exposed: {{ event.total_people_exposed }}
    • +
    • Total Buildings Exposed: {{ event.total_buildings_exposed }}
    • +
    • Start Date Time: {{ event.start_datetime|default:"N/A" }}
    • +
    • End Date Time: {{ event.end_datetime|default:"N/A" }}
    • +
    +
  • + {% endfor %} +
+ {% else %} +

N/A

+ {% endif %} + +

Related Events in GO Platform

+ {% if related_go_events %} +
    + {% for event in related_go_events|slice:":3" %} +
  • + + {{ event }} + +
  • + {% endfor %} +
+ {% else %} +

N/A

+ {% endif %} +
+

+ We recommend staying alert and following local safety guidelines. +

+ +

Stay safe,
+ IFRC GO

+
+ +{% include "design/foot2.html" %} diff --git a/notifications/templates/email/alert_system/alert_notification_reply.html b/notifications/templates/email/alert_system/alert_notification_reply.html new file mode 100644 index 000000000..807c151cd --- /dev/null +++ b/notifications/templates/email/alert_system/alert_notification_reply.html @@ -0,0 +1,25 @@ +{% include "design/head3.html" %} + + + + + +
+

+ A new update has been detected for the ongoing alert {{ event_title }} affecting + {% for country in country_name %} + {{ country }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% if start_datetime %} + at {{ start_datetime }} + {% endif %}. +

+

Updated Key Figures

+
    +
  • Total People Exposed: {{ total_people_exposed }}
  • +
  • Total Buildings Exposed: {{ total_buildings_exposed }}
  • +
+
+ +{% include "design/foot2.html" %} diff --git a/notifications/tests.py b/notifications/tests.py index d260b70be..316363435 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -5,21 +5,17 @@ from modeltranslation.utils import build_localized_fieldname from api.factories.country import CountryFactory +from api.factories.disaster_type import DisasterTypeFactory from api.factories.region import RegionFactory from api.models import RegionName from deployments.factories.molnix_tag import MolnixTagFactory from deployments.factories.user import UserFactory from lang.serializers import TranslatedModelSerializerMixin from main.test_case import APITestCase -from notifications.factories import ( - AlertSubscriptionFactory, - HazardTypeFactory, - SurgeAlertFactory, -) +from notifications.factories import AlertSubscriptionFactory, SurgeAlertFactory from notifications.management.commands.ingest_alerts import categories, timeformat from notifications.models import ( AlertSubscription, - HazardType, SurgeAlert, SurgeAlertStatus, SurgeAlertType, @@ -252,8 +248,8 @@ def setUp(self): iso="PH", region=self.region, ) - self.hazard_type1 = HazardTypeFactory.create(type=HazardType.Type.EARTHQUAKE) - self.hazard_type2 = HazardTypeFactory.create(type=HazardType.Type.FLOOD) + self.hazard_type1 = DisasterTypeFactory.create(name="Flood") + self.hazard_type2 = DisasterTypeFactory.create(name="Earthquake") self.alert_subscription = AlertSubscriptionFactory.create( user=self.user1, @@ -308,11 +304,11 @@ def test_update_subscription(self): url = f"/api/v2/alert-subscription/{self.alert_subscription.id}/" data = { "countries": [self.country_1.id], - "alert_per_day": AlertSubscription.AlertPerDay.UNLIMITED, + "alert_per_day": AlertSubscription.AlertPerDay.TEN, } self.authenticate(self.user1) response = self.client.patch(url, data=data, format="json") self.assert_200(response) self.alert_subscription.refresh_from_db() self.assertEqual(self.alert_subscription.countries.first().id, self.country_1.id) - self.assertEqual(self.alert_subscription.alert_per_day, AlertSubscription.AlertPerDay.UNLIMITED) + self.assertEqual(self.alert_subscription.alert_per_day, AlertSubscription.AlertPerDay.TEN)