From 843503b7945069d73e52b8d4b1ce3e64e7b8084c Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Sun, 4 Jan 2026 17:03:56 +0545 Subject: [PATCH 1/6] feat(alert-system): Add email alert setup --- alert_system/admin.py | 44 +- alert_system/dev_views.py | 75 +++ alert_system/factories.py | 39 ++ .../management/commands/alert_notification.py | 26 + .../commands/alert_notification_reply.py | 42 ++ ...alertemailthread_alertemaillog_and_more.py | 53 +++ alert_system/models.py | 141 +++++- alert_system/tasks.py | 187 +++++++- alert_system/tests.py | 448 ++++++++++++++++++ alert_system/utils.py | 43 ++ assets | 2 +- deploy/helm/ifrcgo-helm/values.yaml | 4 +- docker-compose.yml | 4 +- main/sentry.py | 1 + main/urls.py | 2 + notifications/admin.py | 7 +- notifications/enums.py | 1 - notifications/factories.py | 7 +- notifications/filter_set.py | 2 +- ...scription.py => 0016_alertsubscription.py} | 19 +- notifications/models.py | 42 +- notifications/notification.py | 30 +- notifications/serializers.py | 18 +- .../alert_system/alert_notification.html | 69 +++ .../alert_notification_reply.html | 25 + notifications/tests.py | 16 +- 26 files changed, 1248 insertions(+), 99 deletions(-) create mode 100644 alert_system/dev_views.py create mode 100644 alert_system/factories.py create mode 100644 alert_system/management/commands/alert_notification.py create mode 100644 alert_system/management/commands/alert_notification_reply.py create mode 100644 alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py create mode 100644 alert_system/tests.py create mode 100644 alert_system/utils.py rename notifications/migrations/{0016_hazardtype_alertsubscription.py => 0016_alertsubscription.py} (65%) create mode 100644 notifications/templates/email/alert_system/alert_notification.html create mode 100644 notifications/templates/email/alert_system/alert_notification_reply.html 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..00ffe0526 --- /dev/null +++ b/alert_system/management/commands/alert_notification.py @@ -0,0 +1,26 @@ +from django.core.management.base import BaseCommand +from sentry_sdk import monitor + +from alert_system.models import LoadItem +from alert_system.tasks import send_alert_email_notification +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("Sending alert email notification")) + + for item in items.iterator(): + send_alert_email_notification.delay(load_item_id=item.id) + + self.stdout.write(self.style.SUCCESS("All alert notification email send successfully")) diff --git a/alert_system/management/commands/alert_notification_reply.py b/alert_system/management/commands/alert_notification_reply.py new file mode 100644 index 000000000..9f08d510e --- /dev/null +++ b/alert_system/management/commands/alert_notification_reply.py @@ -0,0 +1,42 @@ +from django.core.management.base import BaseCommand + +from alert_system.models import AlertEmailLog, LoadItem +from alert_system.tasks import send_alert_email_replies + +# TODO @sudip-khanal:Configure Sentry monitoring and values.yaml entry once the execution time is confirmed. + + +class Command(BaseCommand): + help = "Send reply emails for new items sharing same correlation id with already sent root emails" + + def handle(self, *args, **options): + + # correlation IDs of already sent emails + correlation_ids = ( + AlertEmailLog.objects.filter( + status=AlertEmailLog.Status.SENT, + ) + .values_list("item__correlation_id", flat=True) + .distinct() + ) + + if not correlation_ids: + self.stdout.write(self.style.NOTICE("No sent emails found for reply to.")) + return + + # New items that belong to same correlation IDs but have Not been emailed yet + items = LoadItem.objects.filter(correlation_id__in=correlation_ids, item_eligible=True, is_past_event=False).exclude( + email_alert_load_item__status=AlertEmailLog.Status.SENT + ) + + if not items.exists(): + self.stdout.write(self.style.NOTICE("No new related items found for replies.")) + return + + self.stdout.write(self.style.NOTICE(f"Queueing {items.count()} reply emails.")) + + # Step 3: Queue reply emails + for item in items.iterator(): + send_alert_email_replies.delay(load_item_id=item.id) + + self.stdout.write(self.style.SUCCESS("All reply emails have been queued successfully.")) diff --git a/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py b/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py new file mode 100644 index 000000000..43fb5ab62 --- /dev/null +++ b/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.26 on 2026-01-04 04:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0016_alertsubscription'), + ('alert_system', '0003_remove_loaditem_related_go_events_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AlertEmailThread', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('correlation_id', models.CharField(db_index=True, 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.')), + ('reply_until', models.DateTimeField(help_text='Replies allowed until this timestamp (root email send date + 30 days).')), + ('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)), + ], + ), + 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')), + ('in_reply_to', models.CharField(blank=True, help_text='Message-ID of the root email this message replies to. Null if this is the root.', max_length=255, null=True, verbose_name='In-Reply-To')), + ('status', models.IntegerField(choices=[(100, 'Pending'), (200, 'Processing'), (300, 'Sent'), (400, 'Failed')], default=100, verbose_name='Email Status')), + ('email_type', models.IntegerField(choices=[(100, 'New email'), (200, 'Reply email')], help_text='Indicates if the email is a new root email or a reply in a thread.', verbose_name='Email Type')), + ('sent_at', models.DateTimeField(blank=True, help_text='Timestamp when the 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(null=True, 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.AddConstraint( + model_name='alertemailthread', + constraint=models.UniqueConstraint(fields=('user', 'correlation_id'), name='unique_user_correlation_thread'), + ), + ] diff --git a/alert_system/models.py b/alert_system/models.py index e4b2f1fcd..9d6eed0d2 100644 --- a/alert_system/models.py +++ b/alert_system/models.py @@ -1,8 +1,11 @@ +from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from api.models import DisasterType, Event +from notifications.models import AlertSubscription class ImpactDetailsEnum: @@ -156,7 +159,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 +223,140 @@ 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, + db_index=True, + 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."), + ) + + reply_until = models.DateTimeField( + help_text=_("Replies allowed until this timestamp (root email send date + 30 days)."), + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "correlation_id"], + name="unique_user_correlation_thread", + ) + ] + + def is_reply_allowed(self) -> bool: + return timezone.now() <= self.reply_until + + def __str__(self): + return f"Email Thread for {self.user.get_full_name()}-{self.root_email_message_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") + + class EmailType(models.IntegerChoices): + NEW = 100, _("New email") + REPLY = 200, _("Reply email") + + 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, + null=True, + 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."), + ) + + in_reply_to = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name=_("In-Reply-To"), + help_text=_("Message-ID of the root email this message replies to. Null if this is the root."), + ) + + status = models.IntegerField( + choices=Status.choices, + default=Status.PENDING, + verbose_name=_("Email Status"), + ) + + email_type = models.IntegerField( + choices=EmailType.choices, + verbose_name=_("Email Type"), + help_text=_("Indicates if the email is a new root email or a reply in a thread."), + ) + + sent_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Sent At"), + help_text=_("Timestamp when the email was successfully sent."), + ) + + thread = models.ForeignKey[AlertEmailThread, AlertEmailThread]( + 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"] diff --git a/alert_system/tasks.py b/alert_system/tasks.py index 9d44ab725..170c66c4b 100644 --- a/alert_system/tasks.py +++ b/alert_system/tasks.py @@ -1,17 +1,25 @@ import logging import uuid from collections import defaultdict +from datetime import timedelta 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.template.loader import render_to_string +from django.utils import timezone from alert_system.etl.base.extraction import PastEventExtractionClass +from alert_system.utils import ( + get_alert_email_context, + get_alert_subscriptions_for_load_item, +) from api.models import Event +from notifications.notification import send_notification 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 +178,180 @@ 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 send_alert_email_notification(load_item_id: int): + + load_item = LoadItem.objects.select_related("connector", "connector__dtype").filter(id=load_item_id).first() + + if not load_item: + return None + + today = timezone.now().date() + + subscriptions = get_alert_subscriptions_for_load_item(load_item) + + if not subscriptions.exists(): + logger.info(f"No alert subscriptions matched for LoadItem ID [{load_item_id}])") + + for subscription in subscriptions: + user = subscription.user + + # Check Daily alert limit + sent_today = AlertEmailLog.objects.filter( + user=user, + subscription=subscription, + status=AlertEmailLog.Status.SENT, + sent_at=today, + ).count() + + if subscription.alert_per_day and sent_today >= subscription.alert_per_day: + logger.info(f"Daily alert limit reached for subscription ID [{subscription.id}] of user [{user.get_full_name()}]") + continue + + # Item-level deduplication check + if AlertEmailLog.objects.filter( + user=user, + subscription=subscription, + item=load_item, + status=AlertEmailLog.Status.SENT, + ).exists(): + logger.info( + f"Duplicate alert skipped for user [{user.get_full_name()}] with subscription ID [{subscription.id}] " + f"loadItem ID [{load_item_id}]" + ) + continue + + # Correlation-level rule (no new root ever) + if AlertEmailThread.objects.filter( + user=user, + correlation_id=load_item.correlation_id, + ).exists(): + logger.info( + f"Root email skipped (existing thread) for user [{user.get_full_name()}] " + f"with correlation ID [{load_item.correlation_id}]", + ) + + continue + + message_id = str(uuid.uuid4()) + + email_log = AlertEmailLog.objects.create( + user=user, + subscription=subscription, + item=load_item, + status=AlertEmailLog.Status.PROCESSING, + message_id=message_id, + email_type=AlertEmailLog.EmailType.NEW, + ) + + try: + email_subject = f"New Hazard Alert:{load_item.event_title}" + email_context = get_alert_email_context(load_item, user) + email_body = render_to_string("email/alert_system/alert_notification.html", email_context) + email_type = "Alert Email Notification" + send_notification( + subject=email_subject, + recipients=user.email, + message_id=message_id, + html=email_body, + mailtype=email_type, + ) + + email_log.status = AlertEmailLog.Status.SENT + email_log.sent_at = timezone.now() + + thread, created = AlertEmailThread.objects.get_or_create( + user=user, + correlation_id=load_item.correlation_id, + root_email_message_id=message_id, + root_message_sent_at=timezone.now(), + reply_until=timezone.now() + timedelta(days=30), + ) + + email_log.thread = thread + email_log.save(update_fields=["status", "sent_at", "thread"]) + logger.info( + f"Alert email sent successfully to user [{user.get_full_name()}] " + f"with subscription ID [{subscription.id}] loadItem ID [{load_item_id}] " + ) + except Exception as e: + email_log.status = AlertEmailLog.Status.FAILED + email_log.save(update_fields=["status"]) + logger.warning(f"Alert email sent failed with exception: {e}", exc_info=True) + + +@shared_task() +def send_alert_email_replies(load_item_id: int): + + load_item = LoadItem.objects.filter(id=load_item_id).first() + if not load_item: + return + + threads = AlertEmailThread.objects.filter( + correlation_id=load_item.correlation_id, + ).select_related("user") + + if not threads.exists(): + logger.info( + f"No email threads found for correlation ID [{load_item.correlation_id}]", + ) + return + + for thread in threads.iterator(): + if not thread.is_reply_allowed(): + logger.info(f"Reply window expired for thread message ID [{thread.root_email_message_id}]") + continue + + user = thread.user + + # Item-level deduplication check: one reply per item per user + if AlertEmailLog.objects.filter( + user=user, + item=load_item, + status=AlertEmailLog.Status.SENT, + ).exists(): + logger.info( + f"Duplicate reply skipped for user [{user.get_full_name()}] Item ID [{load_item_id}]", + ) + continue + + message_id = str(uuid.uuid4()) + + email_log = AlertEmailLog.objects.create( + user=user, + item=load_item, + status=AlertEmailLog.Status.PROCESSING, + message_id=message_id, + in_reply_to=thread.root_email_message_id, + thread=thread, + email_type=AlertEmailLog.EmailType.REPLY, + ) + + try: + subject = f"Re: Hazard Alert: {load_item.event_title}" + email_context = get_alert_email_context(load_item, user) + email_body = render_to_string("email/alert_system/alert_notification_reply.html", email_context) + email_type = "Alert Email Notification Reply" + + send_notification( + subject=subject, + recipients=user.email, + message_id=message_id, + in_reply_to=thread.root_email_message_id, + html=email_body, + mailtype=email_type, + ) + + email_log.status = AlertEmailLog.Status.SENT + email_log.sent_at = timezone.now() + email_log.save(update_fields=["status", "sent_at"]) + + logger.info( + f"Reply email sent to user [{user.get_full_name()}] with thread root message ID [{thread.root_email_message_id}]" + ) + except Exception as e: + email_log.status = AlertEmailLog.Status.FAILED + email_log.save(update_fields=["status"]) + logger.warning(f"Failed to send reply email with exception: {e}", exc_info=True) diff --git a/alert_system/tests.py b/alert_system/tests.py new file mode 100644 index 000000000..8e525b42b --- /dev/null +++ b/alert_system/tests.py @@ -0,0 +1,448 @@ +from datetime import datetime, timedelta +from unittest import mock + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from alert_system.models import AlertEmailLog, Connector +from alert_system.tasks import send_alert_email_notification, send_alert_email_replies +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 AlertEmailNotificationsTestCase(TestCase): + + def setUp(self): + self.user1 = UserFactory.create(email="testuser1@com") + self.user2 = UserFactory.create(email="testuser2@com") + + self.region = RegionFactory.create() + self.country = CountryFactory.create( + name="Nepal", + iso3="NEP", + iso="NP", + region=self.region, + ) + + self.hazard_type1 = DisasterTypeFactory.create(name="Flood") + self.hazard_type2 = DisasterTypeFactory.create(name="Earthquake") + + self.connector = ConnectorFactory.create( + type=Connector.ConnectorType.GDACS_FLOOD, + dtype=self.hazard_type1, + status=Connector.Status.SUCCESS, + source_url="https://test.com/stac", + ) + + self.subscription = AlertSubscriptionFactory.create( + user=self.user1, + countries=[self.country], + hazard_types=[self.hazard_type1], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, + ) + + self.eligible_item = LoadItemFactory.create( + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Flood in Nepal", + event_description="Heavy flooding reported", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + 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="Ignored Event", + event_description="Should not trigger email", + country_codes=["IND"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + @mock.patch("alert_system.tasks.send_alert_email_notification.delay") + @mock.patch("alert_system.tasks.send_notification") + def test_trigger_command_for_eligible_items( + self, + mock_send_notification, + mock_send_alert_email_notification, + ): + + call_command("alert_notification") + # Task enqueued once with eligible item + mock_send_alert_email_notification.assert_called_once_with(load_item_id=self.eligible_item.id) + send_alert_email_notification(self.eligible_item.id) + mock_send_notification.assert_called_once() + self.assertEqual(AlertEmailLog.objects.count(), 1) + + @mock.patch("alert_system.tasks.send_notification") + def test_alert_email_for_eligible_item(self, mock_send_notification): + + mock_send_notification.return_value = None + send_alert_email_notification(self.eligible_item.id) + mock_send_notification.assert_called_once() + + self.assertEqual(AlertEmailLog.objects.count(), 1) + log = AlertEmailLog.objects.first() + + self.assertEqual(log.user, self.user1) + self.assertEqual(log.item, self.eligible_item) + self.assertEqual(log.subscription, self.subscription) + self.assertEqual(log.status, AlertEmailLog.Status.SENT) + self.assertIsNotNone(log.sent_at) + + @mock.patch("alert_system.tasks.send_notification") + def test_duplicate_email_not_sent(self, mock_send_notification): + # Test Duplicate alerts for same user/item are skipped + AlertEmailLogFactory.create( + user=self.user1, + subscription=self.subscription, + item=self.eligible_item, + message_id="alert-duplicate", + status=AlertEmailLog.Status.SENT, + email_type=AlertEmailLog.EmailType.NEW, + sent_at=timezone.now(), + ) + + send_alert_email_notification(self.eligible_item.id) + + self.assertEqual(AlertEmailLog.objects.count(), 1) + mock_send_notification.assert_not_called() + + @mock.patch("alert_system.tasks.send_notification") + def test_daily_email_notification_limit(self, mock_send_notification): + + for _ in range(self.subscription.alert_per_day): + AlertEmailLogFactory.create( + user=self.user1, + subscription=self.subscription, + item=self.eligible_item, + message_id=f"alert-old-{_}", + status=AlertEmailLog.Status.SENT, + email_type=AlertEmailLog.EmailType.NEW, + sent_at=timezone.now(), + ) + + send_alert_email_notification(self.eligible_item.id) + self.assertEqual( + AlertEmailLog.objects.filter(status=AlertEmailLog.Status.SENT).count(), + self.subscription.alert_per_day, + ) + mock_send_notification.assert_not_called() + + +class AlertEmailReplyTestCase(TestCase): + + def setUp(self): + + self.user = UserFactory.create(email="replyuser@test.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.subscription = AlertSubscriptionFactory.create( + user=self.user, + countries=[self.country], + hazard_types=[self.hazard_type], + ) + + self.item_1 = LoadItemFactory.create( + connector=self.connector, + correlation_id="corr-001", + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Flood in Nepal", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + # Item for reply + self.item_2 = LoadItemFactory.create( + connector=self.connector, + correlation_id="corr-001", + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Flood Update", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + # Thread + root email log + self.thread = AlertEmailThreadFactory.create( + user=self.user, + correlation_id=self.item_1.correlation_id, + root_email_message_id="root-msg-123", + root_message_sent_at=timezone.now(), + reply_until=timezone.now() + timedelta(days=30), + ) + + self.root_email_log = AlertEmailLogFactory.create( + user=self.user, + subscription=self.subscription, + item=self.item_1, + thread=self.thread, + message_id="root-msg-123", + email_type=AlertEmailLog.EmailType.NEW, + status=AlertEmailLog.Status.SENT, + sent_at=timezone.now(), + ) + + @mock.patch("alert_system.tasks.send_notification") + def test_reply_without_root_email(self, mock_send_notification): + """No reply sent if root email doesn't exist""" + # Create item with different correlation_id + new_item = LoadItemFactory.create( + connector=self.connector, + correlation_id="corr-999", + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="No root email item", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + send_alert_email_replies(new_item.id) + + self.assertFalse(AlertEmailLog.objects.filter(email_type=AlertEmailLog.EmailType.REPLY).exists()) + mock_send_notification.assert_not_called() + + @mock.patch("alert_system.tasks.send_alert_email_replies.delay") + def test_new_related_item_reply(self, mock_send_alert_email_replies): + + old_item = LoadItemFactory.create( + connector=self.connector, + correlation_id="corr-0012323", + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="No root email item", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + AlertEmailLogFactory.create( + user=self.user, + subscription=self.subscription, + item=old_item, + status=AlertEmailLog.Status.SENT, + email_type=AlertEmailLog.EmailType.NEW, + ) + + new_item = LoadItemFactory.create( + connector=self.connector, + correlation_id="corr-0012323", + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="No root email item", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + impact_metadata={ + "summary": "Test impact metadata", + "source": "unit-test", + }, + ) + + call_command("alert_notification_reply") + + mock_send_alert_email_replies.assert_called_with(load_item_id=new_item.id) + + @mock.patch("alert_system.tasks.send_notification") + def test_reply_email_within_30_days(self, mock_send_notification): + """Reply is sent when inside 30-day window""" + # Mock timezone.now() + patcher = mock.patch("django.utils.timezone.now") + mock_timezone_now = patcher.start() + mock_now = datetime(2026, 1, 15, 12, 0, 0) # inside 30-day window + mock_timezone_now.return_value = timezone.make_aware(mock_now) + + send_alert_email_replies(self.item_2.id) + + # Fetch reply specifically for the user and item + reply_logs = AlertEmailLog.objects.filter( + email_type=AlertEmailLog.EmailType.REPLY, user=self.user, item=self.item_2, thread=self.thread + ) + self.assertEqual(reply_logs.count(), 1) + mock_send_notification.assert_called_once() + patcher.stop() + + @mock.patch("alert_system.tasks.send_notification") + def test_reply_email_after_30_days(self, mock_send_notification): + """Reply is NOT sent when outside 30-day window""" + # Mock now to a datetime after the 30-day reply window + patcher = mock.patch("django.utils.timezone.now") + mock_timezone_now = patcher.start() + mock_now = datetime(2026, 2, 15, 12, 0, 0) + mock_timezone_now.return_value = timezone.make_aware(mock_now) + + reply_exists = AlertEmailLog.objects.filter( + email_type=AlertEmailLog.EmailType.REPLY, user=self.user, item=self.item_2, thread=self.thread + ).exists() + self.assertFalse(reply_exists) + mock_send_notification.assert_not_called() + patcher.stop() + + @mock.patch("alert_system.tasks.send_notification") + def test_duplicate_reply(self, mock_send_notification): + + # Already sent reply + AlertEmailLogFactory.create( + user=self.user, + subscription=self.subscription, + item=self.item_2, + thread=self.thread, + message_id="reply-msg-001", + email_type=AlertEmailLog.EmailType.REPLY, + status=AlertEmailLog.Status.SENT, + sent_at=timezone.now(), + ) + + send_alert_email_replies(self.item_2.id) + + replies = AlertEmailLog.objects.filter(email_type=AlertEmailLog.EmailType.REPLY) + self.assertEqual(replies.count(), 1) + mock_send_notification.assert_not_called() + + @mock.patch("alert_system.tasks.send_notification") + def test_reply_to_multiple_users_separate_threads(self, mock_send_notification): + # Mock timezone + patcher = mock.patch("django.utils.timezone.now") + mock_timezone_now = patcher.start() + mock_now = datetime(2026, 1, 15, 12, 0, 0) # inside 30-day window + mock_timezone_now.return_value = timezone.make_aware(mock_now) + + # New item for this test + new_item = LoadItemFactory.create( + connector=self.connector, + correlation_id="corr-multi-users", + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title="Flood Multi-user", + country_codes=["NEP"], + total_people_exposed=100, + total_buildings_exposed=50, + impact_metadata={"summary": "Test", "source": "unit-test"}, + ) + + # Thread for self.user + thread_user1 = AlertEmailThreadFactory.create( + user=self.user, + correlation_id=new_item.correlation_id, + root_email_message_id="root-msg-user1", + root_message_sent_at=timezone.now(), + reply_until=timezone.now() + timedelta(days=30), + ) + AlertEmailLogFactory.create( + user=self.user, + subscription=self.subscription, + item=new_item, + thread=thread_user1, + message_id="root-msg-user1", + email_type=AlertEmailLog.EmailType.NEW, + status=AlertEmailLog.Status.PROCESSING, + ) + + # Second user + thread + user2 = UserFactory.create(email="second@test.com") + subscription2 = AlertSubscriptionFactory.create( + user=user2, + countries=[self.country], + hazard_types=[self.hazard_type], + ) + thread_user2 = AlertEmailThreadFactory.create( + user=user2, + correlation_id=new_item.correlation_id, + root_email_message_id="root-msg-user2", + root_message_sent_at=timezone.now(), + reply_until=timezone.now() + timedelta(days=30), + ) + AlertEmailLogFactory.create( + user=user2, + subscription=subscription2, + item=new_item, + thread=thread_user2, + message_id="root-msg-user2", + email_type=AlertEmailLog.EmailType.NEW, + status=AlertEmailLog.Status.PROCESSING, + ) + + # Send replies + send_alert_email_replies(new_item.id) + + replies = AlertEmailLog.objects.filter(email_type=AlertEmailLog.EmailType.REPLY, item=new_item) + self.assertEqual(replies.count(), 2) + self.assertEqual(mock_send_notification.call_count, 2) + + reply_user1 = replies.get(user=self.user) + reply_user2 = replies.get(user=user2) + + self.assertEqual(reply_user1.thread, thread_user1) + self.assertEqual(reply_user2.thread, thread_user2) + self.assertEqual(reply_user1.in_reply_to, thread_user1.root_email_message_id) + self.assertEqual(reply_user2.in_reply_to, thread_user2.root_email_message_id) + self.assertNotEqual(reply_user1.message_id, reply_user2.message_id) + patcher.stop() diff --git a/alert_system/utils.py b/alert_system/utils.py new file mode 100644 index 000000000..12465a42d --- /dev/null +++ b/alert_system/utils.py @@ -0,0 +1,43 @@ +from django.conf import settings +from django.db.models import Q + +from alert_system.models import LoadItem +from api.models import Country +from notifications.models import AlertSubscription + + +def get_alert_email_context(load_item: LoadItem, 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_for_load_item(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() + ) diff --git a/assets b/assets index aeda366d7..ba3238aa2 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit aeda366d7d172e5ed7eca71665937f2bc4fb0ec6 +Subproject commit ba3238aa2c5ef8f2741c8d6de6f2e0c9db2d11b9 diff --git a/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 3249bc39f..7b08054f7 100644 --- a/deploy/helm/ifrcgo-helm/values.yaml +++ b/deploy/helm/ifrcgo-helm/values.yaml @@ -283,8 +283,8 @@ cronjobs: schedule: '0 0 * * 0' - command: 'poll_usgs_earthquake' schedule: '0 0 * * 0' - # - command: 'notify_validators' - # schedule: '0 0 * * *' + - command: 'alert_notification' + schedule: '0 */3 * * *' # https://github.com/jazzband/django-oauth-toolkit/blob/master/docs/management_commands.rst#cleartokens - command: 'oauth_cleartokens' schedule: '0 1 * * *' diff --git a/docker-compose.yml b/docker-compose.yml index 21e531718..eb5509a32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,13 +70,13 @@ x-server: &base_server_setup services: db: - image: postgis/postgis:15-3.4-alpine + image: postgis/postgis:17-3.5-alpine environment: POSTGRES_PASSWORD: test POSTGRES_USER: test POSTGRES_DB: test volumes: - - './.db/pg-15:/var/lib/postgresql/data' + - './.db/pg-17:/var/lib/postgresql/data' extra_hosts: - "host.docker.internal:host-gateway" diff --git a/main/sentry.py b/main/sentry.py index d2596272b..a89ead433 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -133,6 +133,7 @@ class SentryMonitor(models.TextChoices): 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 */3 * * *" @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..5c6676b48 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.NumberFilter(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) From 9457eb60b4a2791ab33e069ef83618ce09d341e2 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Sun, 4 Jan 2026 17:52:39 +0545 Subject: [PATCH 2/6] chore(assets): Update assets commit reference --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index ba3238aa2..b1b075707 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit ba3238aa2c5ef8f2741c8d6de6f2e0c9db2d11b9 +Subproject commit b1b075707a4d039ab2cfa37e995e2881a9c44b0e From 73f95f4182b26addc15355e9485d95c156e2cf94 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Tue, 6 Jan 2026 16:47:40 +0545 Subject: [PATCH 3/6] feat(alert-system): feat(alert-system): update alert email task and fix notification tests --- .../management/commands/alert_notification.py | 8 +- .../commands/alert_notification_reply.py | 42 -- ...alertemailthread_alertemaillog_and_more.py | 30 +- alert_system/models.py | 58 +- alert_system/tasks.py | 217 ++---- alert_system/tests.py | 615 ++++++++++-------- alert_system/utils.py | 86 ++- deploy/helm/ifrcgo-helm/values.yaml | 2 +- docker-compose.yml | 4 +- main/sentry.py | 2 +- notifications/filter_set.py | 2 +- 11 files changed, 530 insertions(+), 536 deletions(-) delete mode 100644 alert_system/management/commands/alert_notification_reply.py diff --git a/alert_system/management/commands/alert_notification.py b/alert_system/management/commands/alert_notification.py index 00ffe0526..0b3c4e675 100644 --- a/alert_system/management/commands/alert_notification.py +++ b/alert_system/management/commands/alert_notification.py @@ -2,7 +2,7 @@ from sentry_sdk import monitor from alert_system.models import LoadItem -from alert_system.tasks import send_alert_email_notification +from alert_system.tasks import process_email_alert from main.sentry import SentryMonitor @@ -18,9 +18,9 @@ def handle(self, *args, **options): self.stdout.write(self.style.NOTICE("No eligible items found")) return - self.stdout.write(self.style.NOTICE("Sending alert email notification")) + self.stdout.write(self.style.NOTICE(f"Queueing {items.count()} items for alert email notification.")) for item in items.iterator(): - send_alert_email_notification.delay(load_item_id=item.id) + process_email_alert.delay(load_item_id=item.id) - self.stdout.write(self.style.SUCCESS("All alert notification email send successfully")) + self.stdout.write(self.style.SUCCESS("All alert notification email queued successfully")) diff --git a/alert_system/management/commands/alert_notification_reply.py b/alert_system/management/commands/alert_notification_reply.py deleted file mode 100644 index 9f08d510e..000000000 --- a/alert_system/management/commands/alert_notification_reply.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.core.management.base import BaseCommand - -from alert_system.models import AlertEmailLog, LoadItem -from alert_system.tasks import send_alert_email_replies - -# TODO @sudip-khanal:Configure Sentry monitoring and values.yaml entry once the execution time is confirmed. - - -class Command(BaseCommand): - help = "Send reply emails for new items sharing same correlation id with already sent root emails" - - def handle(self, *args, **options): - - # correlation IDs of already sent emails - correlation_ids = ( - AlertEmailLog.objects.filter( - status=AlertEmailLog.Status.SENT, - ) - .values_list("item__correlation_id", flat=True) - .distinct() - ) - - if not correlation_ids: - self.stdout.write(self.style.NOTICE("No sent emails found for reply to.")) - return - - # New items that belong to same correlation IDs but have Not been emailed yet - items = LoadItem.objects.filter(correlation_id__in=correlation_ids, item_eligible=True, is_past_event=False).exclude( - email_alert_load_item__status=AlertEmailLog.Status.SENT - ) - - if not items.exists(): - self.stdout.write(self.style.NOTICE("No new related items found for replies.")) - return - - self.stdout.write(self.style.NOTICE(f"Queueing {items.count()} reply emails.")) - - # Step 3: Queue reply emails - for item in items.iterator(): - send_alert_email_replies.delay(load_item_id=item.id) - - self.stdout.write(self.style.SUCCESS("All reply emails have been queued successfully.")) diff --git a/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py b/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py index 43fb5ab62..f1eb5ad15 100644 --- a/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py +++ b/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2026-01-04 04:25 +# Generated by Django 4.2.26 on 2026-01-08 17:27 from django.conf import settings from django.db import migrations, models @@ -18,25 +18,27 @@ class Migration(migrations.Migration): name='AlertEmailThread', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('correlation_id', models.CharField(db_index=True, help_text='Identifier linking related LoadItems into the same email thread.', max_length=255)), + ('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.')), - ('reply_until', models.DateTimeField(help_text='Replies allowed until this timestamp (root email send date + 30 days).')), ('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')), - ('in_reply_to', models.CharField(blank=True, help_text='Message-ID of the root email this message replies to. Null if this is the root.', max_length=255, null=True, verbose_name='In-Reply-To')), - ('status', models.IntegerField(choices=[(100, 'Pending'), (200, 'Processing'), (300, 'Sent'), (400, 'Failed')], default=100, verbose_name='Email Status')), - ('email_type', models.IntegerField(choices=[(100, 'New email'), (200, 'Reply email')], help_text='Indicates if the email is a new root email or a reply in a thread.', verbose_name='Email Type')), - ('sent_at', models.DateTimeField(blank=True, help_text='Timestamp when the email was successfully sent.', null=True, verbose_name='Sent At')), + ('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(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='email_alert_subscription', to='notifications.alertsubscription', verbose_name='Alert Subscription')), + ('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')), ], @@ -46,8 +48,16 @@ class Migration(migrations.Migration): 'ordering': ['-id'], }, ), - migrations.AddConstraint( + migrations.AddIndex( model_name='alertemailthread', - constraint=models.UniqueConstraint(fields=('user', 'correlation_id'), name='unique_user_correlation_thread'), + 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 9d6eed0d2..7b784b068 100644 --- a/alert_system/models.py +++ b/alert_system/models.py @@ -1,7 +1,6 @@ from django.conf import settings from django.contrib.postgres.fields import ArrayField from django.db import models -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from api.models import DisasterType, Event @@ -227,7 +226,7 @@ class Meta: class AlertEmailThread(models.Model): """ - Represents a single email conversation (thread) for alert emails. + Represents a single email conversation thread for alert emails. """ user = models.ForeignKey( @@ -238,7 +237,6 @@ class AlertEmailThread(models.Model): correlation_id = models.CharField( max_length=255, - db_index=True, help_text=_("Identifier linking related LoadItems into the same email thread."), ) @@ -252,29 +250,24 @@ class AlertEmailThread(models.Model): help_text=_("Timestamp when the root email was sent."), ) - reply_until = models.DateTimeField( - help_text=_("Replies allowed until this timestamp (root email send date + 30 days)."), - ) - created_at = models.DateTimeField(auto_now_add=True) class Meta: - constraints = [ - models.UniqueConstraint( - fields=["user", "correlation_id"], - name="unique_user_correlation_thread", - ) + verbose_name = _("Email Thread") + verbose_name_plural = _("Email Threads") + ordering = ["-id"] + indexes = [ + models.Index(fields=["correlation_id", "user"]), ] - def is_reply_allowed(self) -> bool: - return timezone.now() <= self.reply_until - def __str__(self): - return f"Email Thread for {self.user.get_full_name()}-{self.root_email_message_id}" + 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.""" + """ + Log of alert emails sent to users, tracking status, type, and threading. + """ class Status(models.IntegerChoices): PENDING = 100, _("Pending") @@ -282,21 +275,15 @@ class Status(models.IntegerChoices): SENT = 300, _("Sent") FAILED = 400, _("Failed") - class EmailType(models.IntegerChoices): - NEW = 100, _("New email") - REPLY = 200, _("Reply email") - 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, - null=True, related_name="email_alert_subscription", verbose_name=_("Alert Subscription"), ) @@ -315,34 +302,21 @@ class EmailType(models.IntegerChoices): help_text=_("Unique Message-ID of email for tracking and threading."), ) - in_reply_to = models.CharField( - max_length=255, - null=True, - blank=True, - verbose_name=_("In-Reply-To"), - help_text=_("Message-ID of the root email this message replies to. Null if this is the root."), - ) - status = models.IntegerField( choices=Status.choices, default=Status.PENDING, + db_index=True, verbose_name=_("Email Status"), ) - email_type = models.IntegerField( - choices=EmailType.choices, - verbose_name=_("Email Type"), - help_text=_("Indicates if the email is a new root email or a reply in a thread."), - ) - - sent_at = models.DateTimeField( + email_sent_at = models.DateTimeField( null=True, blank=True, verbose_name=_("Sent At"), - help_text=_("Timestamp when the email was successfully sent."), + help_text=_("Timestamp when email was successfully sent."), ) - thread = models.ForeignKey[AlertEmailThread, AlertEmailThread]( + thread = models.ForeignKey( AlertEmailThread, on_delete=models.CASCADE, related_name="email_alert_thread", @@ -360,3 +334,7 @@ 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 170c66c4b..7a4388014 100644 --- a/alert_system/tasks.py +++ b/alert_system/tasks.py @@ -1,22 +1,16 @@ import logging import uuid from collections import defaultdict -from datetime import timedelta 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.template.loader import render_to_string +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_email_context, - get_alert_subscriptions_for_load_item, -) +from alert_system.utils import get_alert_subscriptions, send_alert_email_notification from api.models import Event -from notifications.notification import send_notification from .helpers import get_connector_processor, set_connector_status from .models import AlertEmailLog, AlertEmailThread, Connector, LoadItem @@ -181,177 +175,72 @@ def process_connector_task(connector_id): @shared_task() -def send_alert_email_notification(load_item_id: int): - +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: - return None - - today = timezone.now().date() - - subscriptions = get_alert_subscriptions_for_load_item(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}])") - - for subscription in subscriptions: - user = subscription.user - - # Check Daily alert limit - sent_today = AlertEmailLog.objects.filter( - user=user, - subscription=subscription, - status=AlertEmailLog.Status.SENT, - sent_at=today, - ).count() - - if subscription.alert_per_day and sent_today >= subscription.alert_per_day: - logger.info(f"Daily alert limit reached for subscription ID [{subscription.id}] of user [{user.get_full_name()}]") - continue + logger.info(f"No alert subscriptions matched for LoadItem ID [{load_item_id}]") + return - # Item-level deduplication check - if AlertEmailLog.objects.filter( - user=user, - subscription=subscription, - item=load_item, + 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, - ).exists(): - logger.info( - f"Duplicate alert skipped for user [{user.get_full_name()}] with subscription ID [{subscription.id}] " - f"loadItem ID [{load_item_id}]" - ) - continue - - # Correlation-level rule (no new root ever) - if AlertEmailThread.objects.filter( - user=user, - correlation_id=load_item.correlation_id, - ).exists(): - logger.info( - f"Root email skipped (existing thread) for user [{user.get_full_name()}] " - f"with correlation ID [{load_item.correlation_id}]", - ) - - continue - - message_id = str(uuid.uuid4()) - - email_log = AlertEmailLog.objects.create( - user=user, - subscription=subscription, - item=load_item, - status=AlertEmailLog.Status.PROCESSING, - message_id=message_id, - email_type=AlertEmailLog.EmailType.NEW, + 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") + ) - try: - email_subject = f"New Hazard Alert:{load_item.event_title}" - email_context = get_alert_email_context(load_item, user) - email_body = render_to_string("email/alert_system/alert_notification.html", email_context) - email_type = "Alert Email Notification" - send_notification( - subject=email_subject, - recipients=user.email, - message_id=message_id, - html=email_body, - mailtype=email_type, - ) - - email_log.status = AlertEmailLog.Status.SENT - email_log.sent_at = timezone.now() - - thread, created = AlertEmailThread.objects.get_or_create( - user=user, - correlation_id=load_item.correlation_id, - root_email_message_id=message_id, - root_message_sent_at=timezone.now(), - reply_until=timezone.now() + timedelta(days=30), - ) - - email_log.thread = thread - email_log.save(update_fields=["status", "sent_at", "thread"]) - logger.info( - f"Alert email sent successfully to user [{user.get_full_name()}] " - f"with subscription ID [{subscription.id}] loadItem ID [{load_item_id}] " - ) - except Exception as e: - email_log.status = AlertEmailLog.Status.FAILED - email_log.save(update_fields=["status"]) - logger.warning(f"Alert email sent failed with exception: {e}", exc_info=True) - - -@shared_task() -def send_alert_email_replies(load_item_id: int): - - load_item = LoadItem.objects.filter(id=load_item_id).first() - if not load_item: - return - - threads = AlertEmailThread.objects.filter( + # 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") - if not threads.exists(): - logger.info( - f"No email threads found for correlation ID [{load_item.correlation_id}]", - ) - return + existing_threads = {thread.user.id: thread for thread in threads_qs} - for thread in threads.iterator(): - if not thread.is_reply_allowed(): - logger.info(f"Reply window expired for thread message ID [{thread.root_email_message_id}]") - continue + for subscription in subscriptions: + user = subscription.user + user_id: int = user.id + subscription_id: int = subscription.id - user = thread.user + # Reply if this specific user has an existing thread + thread = existing_threads.get(user_id) + is_reply: bool = thread is not None - # Item-level deduplication check: one reply per item per user - if AlertEmailLog.objects.filter( - user=user, - item=load_item, - status=AlertEmailLog.Status.SENT, - ).exists(): - logger.info( - f"Duplicate reply skipped for user [{user.get_full_name()}] Item ID [{load_item_id}]", - ) + # 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 - message_id = str(uuid.uuid4()) - - email_log = AlertEmailLog.objects.create( - user=user, - item=load_item, - status=AlertEmailLog.Status.PROCESSING, - message_id=message_id, - in_reply_to=thread.root_email_message_id, - thread=thread, - email_type=AlertEmailLog.EmailType.REPLY, - ) - - try: - subject = f"Re: Hazard Alert: {load_item.event_title}" - email_context = get_alert_email_context(load_item, user) - email_body = render_to_string("email/alert_system/alert_notification_reply.html", email_context) - email_type = "Alert Email Notification Reply" - - send_notification( - subject=subject, - recipients=user.email, - message_id=message_id, - in_reply_to=thread.root_email_message_id, - html=email_body, - mailtype=email_type, - ) - - email_log.status = AlertEmailLog.Status.SENT - email_log.sent_at = timezone.now() - email_log.save(update_fields=["status", "sent_at"]) + # 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 - logger.info( - f"Reply email sent to user [{user.get_full_name()}] with thread root message ID [{thread.root_email_message_id}]" - ) - except Exception as e: - email_log.status = AlertEmailLog.Status.FAILED - email_log.save(update_fields=["status"]) - logger.warning(f"Failed to send reply email with exception: {e}", exc_info=True) + 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 index 8e525b42b..8c9af34a8 100644 --- a/alert_system/tests.py +++ b/alert_system/tests.py @@ -1,12 +1,12 @@ -from datetime import datetime, timedelta 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, Connector -from alert_system.tasks import send_alert_email_notification, send_alert_email_replies +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 @@ -22,11 +22,12 @@ ) -class AlertEmailNotificationsTestCase(TestCase): +class AlertEmailNotificationTestCase(TestCase): + """Comprehensive test suite for alert email notifications (both root and reply emails)""" def setUp(self): - self.user1 = UserFactory.create(email="testuser1@com") - self.user2 = UserFactory.create(email="testuser2@com") + self.user1 = UserFactory.create(email="testuser1@example.com") + self.user2 = UserFactory.create(email="testuser2@example.com") self.region = RegionFactory.create() self.country = CountryFactory.create( @@ -36,33 +37,40 @@ def setUp(self): region=self.region, ) - self.hazard_type1 = DisasterTypeFactory.create(name="Flood") - self.hazard_type2 = DisasterTypeFactory.create(name="Earthquake") + self.hazard_type = DisasterTypeFactory.create(name="Flood") self.connector = ConnectorFactory.create( type=Connector.ConnectorType.GDACS_FLOOD, - dtype=self.hazard_type1, + dtype=self.hazard_type, status=Connector.Status.SUCCESS, source_url="https://test.com/stac", ) - self.subscription = AlertSubscriptionFactory.create( + self.subscription1 = AlertSubscriptionFactory.create( user=self.user1, countries=[self.country], - hazard_types=[self.hazard_type1], + 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="Flood in Nepal", - event_description="Heavy flooding reported", + event_title="Test Flood Event", + event_description="Initial flood event", country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, + total_people_exposed=10, + total_buildings_exposed=5, impact_metadata={ "summary": "Test impact metadata", "source": "unit-test", @@ -74,9 +82,9 @@ def setUp(self): item_eligible=False, is_past_event=False, start_datetime=timezone.now(), - event_title="Ignored Event", - event_description="Should not trigger email", + event_title="Test Event", country_codes=["IND"], + event_description="Should not trigger email", total_people_exposed=10, total_buildings_exposed=5, impact_metadata={ @@ -85,364 +93,435 @@ def setUp(self): }, ) - @mock.patch("alert_system.tasks.send_alert_email_notification.delay") - @mock.patch("alert_system.tasks.send_notification") - def test_trigger_command_for_eligible_items( - self, - mock_send_notification, - mock_send_alert_email_notification, - ): - - call_command("alert_notification") - # Task enqueued once with eligible item - mock_send_alert_email_notification.assert_called_once_with(load_item_id=self.eligible_item.id) - send_alert_email_notification(self.eligible_item.id) - mock_send_notification.assert_called_once() - self.assertEqual(AlertEmailLog.objects.count(), 1) - - @mock.patch("alert_system.tasks.send_notification") - def test_alert_email_for_eligible_item(self, mock_send_notification): + @mock.patch("alert_system.utils.send_notification") + def test_sent_email_for_eligible_item(self, mock_send_notification): - mock_send_notification.return_value = None - send_alert_email_notification(self.eligible_item.id) - mock_send_notification.assert_called_once() + process_email_alert(self.eligible_item.id) - self.assertEqual(AlertEmailLog.objects.count(), 1) - log = AlertEmailLog.objects.first() + 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.subscription) + self.assertEqual(log.subscription, self.subscription1) self.assertEqual(log.status, AlertEmailLog.Status.SENT) - self.assertIsNotNone(log.sent_at) + self.assertIsNotNone(log.email_sent_at) - @mock.patch("alert_system.tasks.send_notification") - def test_duplicate_email_not_sent(self, mock_send_notification): - # Test Duplicate alerts for same user/item are skipped - AlertEmailLogFactory.create( - user=self.user1, - subscription=self.subscription, - item=self.eligible_item, - message_id="alert-duplicate", - status=AlertEmailLog.Status.SENT, - email_type=AlertEmailLog.EmailType.NEW, - sent_at=timezone.now(), - ) + 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) - send_alert_email_notification(self.eligible_item.id) + mock_send_notification.assert_called() - self.assertEqual(AlertEmailLog.objects.count(), 1) - mock_send_notification.assert_not_called() + @mock.patch("alert_system.utils.send_notification") + def test_sent_email_to_multiple_users(self, mock_send_notification): - @mock.patch("alert_system.tasks.send_notification") - def test_daily_email_notification_limit(self, mock_send_notification): + process_email_alert(self.eligible_item.id) - for _ in range(self.subscription.alert_per_day): - AlertEmailLogFactory.create( - user=self.user1, - subscription=self.subscription, - item=self.eligible_item, - message_id=f"alert-old-{_}", - status=AlertEmailLog.Status.SENT, - email_type=AlertEmailLog.EmailType.NEW, - sent_at=timezone.now(), - ) + logs = AlertEmailLog.objects.filter(item=self.eligible_item, status=AlertEmailLog.Status.SENT) + self.assertEqual(logs.count(), 2) - send_alert_email_notification(self.eligible_item.id) - self.assertEqual( - AlertEmailLog.objects.filter(status=AlertEmailLog.Status.SENT).count(), - self.subscription.alert_per_day, - ) - mock_send_notification.assert_not_called() + threads = AlertEmailThread.objects.filter(correlation_id=self.eligible_item.correlation_id) + self.assertEqual(threads.count(), 2) + self.assertEqual(mock_send_notification.call_count, 2) -class AlertEmailReplyTestCase(TestCase): + # 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) - def setUp(self): + 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) - self.user = UserFactory.create(email="replyuser@test.com") + @mock.patch("alert_system.utils.send_notification") + def test_daily_email_alert_limit(self, mock_send_notification): - self.region = RegionFactory.create() - self.country = CountryFactory.create( - name="Nepal", - iso3="NEP", - iso="NP", + user = UserFactory.create(email="t@example.com") + country = CountryFactory.create( + name="Philippines", + iso3="PHI", + iso="PH", 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.subscription = AlertSubscriptionFactory.create( - user=self.user, - countries=[self.country], + subscription = AlertSubscriptionFactory.create( + user=user, + countries=[country], hazard_types=[self.hazard_type], + alert_per_day=AlertSubscription.AlertPerDay.FIVE, ) - self.item_1 = LoadItemFactory.create( + item = LoadItemFactory.create( connector=self.connector, - correlation_id="corr-001", item_eligible=True, is_past_event=False, start_datetime=timezone.now(), - event_title="Flood in Nepal", - country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, + 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", }, ) - # Item for reply - self.item_2 = LoadItemFactory.create( + 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): + + initial_item = LoadItemFactory.create( + correlation_id="corr-reply-001", connector=self.connector, - correlation_id="corr-001", item_eligible=True, is_past_event=False, start_datetime=timezone.now(), - event_title="Flood Update", + event_title="Initial Flood", country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, - impact_metadata={ - "summary": "Test impact metadata", - "source": "unit-test", - }, + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, ) - # Thread + root email log - self.thread = AlertEmailThreadFactory.create( - user=self.user, - correlation_id=self.item_1.correlation_id, + thread = AlertEmailThreadFactory.create( + user=self.user1, + correlation_id=initial_item.correlation_id, root_email_message_id="root-msg-123", root_message_sent_at=timezone.now(), - reply_until=timezone.now() + timedelta(days=30), ) - self.root_email_log = AlertEmailLogFactory.create( - user=self.user, - subscription=self.subscription, - item=self.item_1, - thread=self.thread, + AlertEmailLogFactory.create( + user=self.user1, + subscription=self.subscription1, + item=initial_item, + thread=thread, message_id="root-msg-123", - email_type=AlertEmailLog.EmailType.NEW, status=AlertEmailLog.Status.SENT, - sent_at=timezone.now(), + email_sent_at=timezone.now(), ) - @mock.patch("alert_system.tasks.send_notification") - def test_reply_without_root_email(self, mock_send_notification): - """No reply sent if root email doesn't exist""" - # Create item with different correlation_id - new_item = LoadItemFactory.create( + # Create update item with same correlation_id + update_item = LoadItemFactory.create( + correlation_id="corr-reply-001", connector=self.connector, - correlation_id="corr-999", item_eligible=True, is_past_event=False, start_datetime=timezone.now(), - event_title="No root email item", + event_title="Flood Update", country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, - impact_metadata={ - "summary": "Test impact metadata", - "source": "unit-test", - }, + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "unit-test"}, ) - send_alert_email_replies(new_item.id) + process_email_alert(update_item.id) - self.assertFalse(AlertEmailLog.objects.filter(email_type=AlertEmailLog.EmailType.REPLY).exists()) - mock_send_notification.assert_not_called() + reply_log = AlertEmailLog.objects.get(user=self.user1, item=update_item) + self.assertEqual(reply_log.status, AlertEmailLog.Status.SENT) + self.assertIsNotNone(reply_log.email_sent_at) + mock_send_notification.assert_called_once() + # Verify no new thread was created + threads = AlertEmailThread.objects.filter(correlation_id=initial_item.correlation_id) + self.assertEqual(threads.count(), 1) - @mock.patch("alert_system.tasks.send_alert_email_replies.delay") - def test_new_related_item_reply(self, mock_send_alert_email_replies): + @mock.patch("alert_system.utils.send_notification") + def test_reply_email_to_multiple_users(self, mock_send_notification): - old_item = LoadItemFactory.create( + correlation_id = str(uuid4()) + + # Create initial item + initial_item = LoadItemFactory.create( + correlation_id=correlation_id, connector=self.connector, - correlation_id="corr-0012323", item_eligible=True, is_past_event=False, start_datetime=timezone.now(), - event_title="No root email item", + event_title="Initial Event", country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, - impact_metadata={ - "summary": "Test impact metadata", - "source": "unit-test", - }, + 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.user, - subscription=self.subscription, - item=old_item, + user=self.user2, + subscription=self.subscription2, + item=initial_item, + thread=thread2, + message_id="message-id-2", status=AlertEmailLog.Status.SENT, - email_type=AlertEmailLog.EmailType.NEW, + email_sent_at=timezone.now(), ) - new_item = LoadItemFactory.create( + related_item = LoadItemFactory.create( + correlation_id=correlation_id, connector=self.connector, - correlation_id="corr-0012323", item_eligible=True, is_past_event=False, start_datetime=timezone.now(), - event_title="No root email item", + event_title="Update Event", country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, - impact_metadata={ - "summary": "Test impact metadata", - "source": "unit-test", - }, + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "test"}, ) - call_command("alert_notification_reply") + process_email_alert(related_item.id) - mock_send_alert_email_replies.assert_called_with(load_item_id=new_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.tasks.send_notification") - def test_reply_email_within_30_days(self, mock_send_notification): - """Reply is sent when inside 30-day window""" - # Mock timezone.now() - patcher = mock.patch("django.utils.timezone.now") - mock_timezone_now = patcher.start() - mock_now = datetime(2026, 1, 15, 12, 0, 0) # inside 30-day window - mock_timezone_now.return_value = timezone.make_aware(mock_now) + @mock.patch("alert_system.utils.send_notification") + def test_duplicate_reply(self, mock_send_notification): - send_alert_email_replies(self.item_2.id) + correlation_id = "corr-dup-reply" - # Fetch reply specifically for the user and item - reply_logs = AlertEmailLog.objects.filter( - email_type=AlertEmailLog.EmailType.REPLY, user=self.user, item=self.item_2, thread=self.thread + 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=["NEP"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, ) - self.assertEqual(reply_logs.count(), 1) - mock_send_notification.assert_called_once() - patcher.stop() - - @mock.patch("alert_system.tasks.send_notification") - def test_reply_email_after_30_days(self, mock_send_notification): - """Reply is NOT sent when outside 30-day window""" - # Mock now to a datetime after the 30-day reply window - patcher = mock.patch("django.utils.timezone.now") - mock_timezone_now = patcher.start() - mock_now = datetime(2026, 2, 15, 12, 0, 0) - mock_timezone_now.return_value = timezone.make_aware(mock_now) - - reply_exists = AlertEmailLog.objects.filter( - email_type=AlertEmailLog.EmailType.REPLY, user=self.user, item=self.item_2, thread=self.thread - ).exists() - self.assertFalse(reply_exists) - mock_send_notification.assert_not_called() - patcher.stop() - @mock.patch("alert_system.tasks.send_notification") - def test_duplicate_reply(self, mock_send_notification): + thread = AlertEmailThreadFactory.create( + user=self.user1, + correlation_id=correlation_id, + root_email_message_id="root-123", + root_message_sent_at=timezone.now(), + ) - # Already sent reply + 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=["NEP"], + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "unit-test"}, + ) + + # Create existing reply log AlertEmailLogFactory.create( - user=self.user, - subscription=self.subscription, - item=self.item_2, - thread=self.thread, - message_id="reply-msg-001", - email_type=AlertEmailLog.EmailType.REPLY, + user=self.user1, + subscription=self.subscription1, + item=update_item, + thread=thread, + message_id="reply-123", status=AlertEmailLog.Status.SENT, - sent_at=timezone.now(), + email_sent_at=timezone.now(), ) - send_alert_email_replies(self.item_2.id) + process_email_alert(update_item.id) - replies = AlertEmailLog.objects.filter(email_type=AlertEmailLog.EmailType.REPLY) + replies = AlertEmailLog.objects.filter(user=self.user1, item=update_item) self.assertEqual(replies.count(), 1) mock_send_notification.assert_not_called() - @mock.patch("alert_system.tasks.send_notification") - def test_reply_to_multiple_users_separate_threads(self, mock_send_notification): - # Mock timezone - patcher = mock.patch("django.utils.timezone.now") - mock_timezone_now = patcher.start() - mock_now = datetime(2026, 1, 15, 12, 0, 0) # inside 30-day window - mock_timezone_now.return_value = timezone.make_aware(mock_now) + @mock.patch("alert_system.utils.send_notification") + def test_reply_email_for_daily_limit(self, mock_send_notification): - # New item for this test - new_item = LoadItemFactory.create( + correlation_id = "corr-limit-reply" + LoadItemFactory.create( + correlation_id=correlation_id, connector=self.connector, - correlation_id="corr-multi-users", item_eligible=True, is_past_event=False, start_datetime=timezone.now(), - event_title="Flood Multi-user", + event_title="Initial", country_codes=["NEP"], - total_people_exposed=100, - total_buildings_exposed=50, - impact_metadata={"summary": "Test", "source": "unit-test"}, + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, ) - # Thread for self.user - thread_user1 = AlertEmailThreadFactory.create( - user=self.user, - correlation_id=new_item.correlation_id, - root_email_message_id="root-msg-user1", + AlertEmailThreadFactory.create( + user=self.user1, + correlation_id=correlation_id, + root_email_message_id="root-123", root_message_sent_at=timezone.now(), - reply_until=timezone.now() + timedelta(days=30), ) - AlertEmailLogFactory.create( - user=self.user, - subscription=self.subscription, - item=new_item, - thread=thread_user1, - message_id="root-msg-user1", - email_type=AlertEmailLog.EmailType.NEW, - status=AlertEmailLog.Status.PROCESSING, + + for i in range(self.subscription1.alert_per_day): + other_item = LoadItemFactory.create( + correlation_id=f"corr-other-{i}", + connector=self.connector, + item_eligible=True, + is_past_event=False, + start_datetime=timezone.now(), + event_title=f"Event {i}", + country_codes=["NEP"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Test", "source": "unit-test"}, + ) + AlertEmailLogFactory.create( + user=self.user1, + subscription=self.subscription1, + item=other_item, + status=AlertEmailLog.Status.SENT, + email_sent_at=timezone.now(), + message_id=str(uuid4()), + ) + + 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", + country_codes=["NEP"], + total_people_exposed=20, + total_buildings_exposed=10, + impact_metadata={"summary": "Update", "source": "unit-test"}, ) - # Second user + thread - user2 = UserFactory.create(email="second@test.com") - subscription2 = AlertSubscriptionFactory.create( - user=user2, - countries=[self.country], - hazard_types=[self.hazard_type], + process_email_alert(related_item.id) + + # Should not create reply log + reply_exists = AlertEmailLog.objects.filter(user=self.user1, item=related_item).exists() + self.assertFalse(reply_exists) + mock_send_notification.assert_not_called() + + @mock.patch("alert_system.utils.send_notification") + def test_reply_without_subscription(self, mock_send_notification): + + correlation_id = str(uuid4()) + item = 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=["NEP"], + total_people_exposed=10, + total_buildings_exposed=5, + impact_metadata={"summary": "Initial", "source": "unit-test"}, ) - thread_user2 = AlertEmailThreadFactory.create( - user=user2, - correlation_id=new_item.correlation_id, - root_email_message_id="root-msg-user2", + + AlertEmailThreadFactory.create( + user=self.user1, + correlation_id=correlation_id, + root_email_message_id="root-123", root_message_sent_at=timezone.now(), - reply_until=timezone.now() + timedelta(days=30), - ) - AlertEmailLogFactory.create( - user=user2, - subscription=subscription2, - item=new_item, - thread=thread_user2, - message_id="root-msg-user2", - email_type=AlertEmailLog.EmailType.NEW, - status=AlertEmailLog.Status.PROCESSING, ) - # Send replies - send_alert_email_replies(new_item.id) + # Delete subscription + self.subscription1.delete() - replies = AlertEmailLog.objects.filter(email_type=AlertEmailLog.EmailType.REPLY, item=new_item) - self.assertEqual(replies.count(), 2) - self.assertEqual(mock_send_notification.call_count, 2) + process_email_alert(item.id) + reply_exists = AlertEmailLog.objects.filter(item=item).exists() + self.assertFalse(reply_exists) + mock_send_notification.assert_not_called() - reply_user1 = replies.get(user=self.user) - reply_user2 = replies.get(user=user2) + # 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") - self.assertEqual(reply_user1.thread, thread_user1) - self.assertEqual(reply_user2.thread, thread_user2) - self.assertEqual(reply_user1.in_reply_to, thread_user1.root_email_message_id) - self.assertEqual(reply_user2.in_reply_to, thread_user2.root_email_message_id) - self.assertNotEqual(reply_user1.message_id, reply_user2.message_id) - patcher.stop() + 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 index 12465a42d..adb901c77 100644 --- a/alert_system/utils.py +++ b/alert_system/utils.py @@ -1,12 +1,22 @@ +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 LoadItem +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): +def get_alert_email_context(load_item: LoadItem, user: User): country_names = [] @@ -31,7 +41,7 @@ def get_alert_email_context(load_item: LoadItem, user): return email_context -def get_alert_subscriptions_for_load_item(load_item: LoadItem): +def get_alert_subscriptions(load_item: LoadItem): regions = Country.objects.filter(iso3__in=load_item.country_codes).values_list("region_id", flat=True) @@ -41,3 +51,73 @@ def get_alert_subscriptions_for_load_item(load_item: LoadItem): .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/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 7b08054f7..965e0ef35 100644 --- a/deploy/helm/ifrcgo-helm/values.yaml +++ b/deploy/helm/ifrcgo-helm/values.yaml @@ -284,7 +284,7 @@ cronjobs: - command: 'poll_usgs_earthquake' schedule: '0 0 * * 0' - command: 'alert_notification' - schedule: '0 */3 * * *' + 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/docker-compose.yml b/docker-compose.yml index eb5509a32..21e531718 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,13 +70,13 @@ x-server: &base_server_setup services: db: - image: postgis/postgis:17-3.5-alpine + image: postgis/postgis:15-3.4-alpine environment: POSTGRES_PASSWORD: test POSTGRES_USER: test POSTGRES_DB: test volumes: - - './.db/pg-17:/var/lib/postgresql/data' + - './.db/pg-15:/var/lib/postgresql/data' extra_hosts: - "host.docker.internal:host-gateway" diff --git a/main/sentry.py b/main/sentry.py index a89ead433..a4f4e6e6b 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -133,7 +133,7 @@ class SentryMonitor(models.TextChoices): 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 */3 * * *" + ALERT_NOTIFICATION = "alert_notification", "0 */2 * * *" @staticmethod def load_cron_data() -> typing.List[typing.Tuple[str, str]]: diff --git a/notifications/filter_set.py b/notifications/filter_set.py index 5c6676b48..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__name", 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: From 4d884a882468433c16d1d81f490a175bf4c9184d Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 9 Jan 2026 16:48:14 +0545 Subject: [PATCH 4/6] chore(alert-system): Update duplicate reply tests for multi-user subscriptions --- alert_system/tests.py | 178 +++++++++++++++--------------------------- assets | 2 +- 2 files changed, 62 insertions(+), 118 deletions(-) diff --git a/alert_system/tests.py b/alert_system/tests.py index 8c9af34a8..6919544f1 100644 --- a/alert_system/tests.py +++ b/alert_system/tests.py @@ -202,46 +202,59 @@ def test_daily_email_alert_limit(self, mock_send_notification): @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="corr-reply-001", + correlation_id=str(uuid4()), connector=self.connector, item_eligible=True, is_past_event=False, start_datetime=timezone.now(), event_title="Initial Flood", - country_codes=["NEP"], + country_codes=["CHN"], total_people_exposed=10, total_buildings_exposed=5, impact_metadata={"summary": "Initial", "source": "unit-test"}, ) thread = AlertEmailThreadFactory.create( - user=self.user1, + user=user, correlation_id=initial_item.correlation_id, - root_email_message_id="root-msg-123", + root_email_message_id=str(uuid4()), root_message_sent_at=timezone.now(), ) AlertEmailLogFactory.create( - user=self.user1, - subscription=self.subscription1, + user=user, + subscription=subscription, item=initial_item, thread=thread, - message_id="root-msg-123", + message_id=str(uuid4()), status=AlertEmailLog.Status.SENT, email_sent_at=timezone.now(), ) - # Create update item with same correlation_id update_item = LoadItemFactory.create( - correlation_id="corr-reply-001", + 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=["NEP"], + country_codes=["CHN"], total_people_exposed=20, total_buildings_exposed=10, impact_metadata={"summary": "Update", "source": "unit-test"}, @@ -249,11 +262,12 @@ def test_reply_email_for_existing_thread(self, mock_send_notification): process_email_alert(update_item.id) - reply_log = AlertEmailLog.objects.get(user=self.user1, item=update_item) + 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() - # Verify no new thread was created + threads = AlertEmailThread.objects.filter(correlation_id=initial_item.correlation_id) self.assertEqual(threads.count(), 1) @@ -337,7 +351,20 @@ def test_reply_email_to_multiple_users(self, mock_send_notification): @mock.patch("alert_system.utils.send_notification") def test_duplicate_reply(self, mock_send_notification): - correlation_id = "corr-dup-reply" + 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, @@ -346,14 +373,14 @@ def test_duplicate_reply(self, mock_send_notification): is_past_event=False, start_datetime=timezone.now(), event_title="Initial", - country_codes=["NEP"], + country_codes=["PAK"], total_people_exposed=10, total_buildings_exposed=5, impact_metadata={"summary": "Initial", "source": "unit-test"}, ) thread = AlertEmailThreadFactory.create( - user=self.user1, + user=user, correlation_id=correlation_id, root_email_message_id="root-123", root_message_sent_at=timezone.now(), @@ -366,16 +393,16 @@ def test_duplicate_reply(self, mock_send_notification): is_past_event=False, start_datetime=timezone.now(), event_title="Update", - country_codes=["NEP"], + country_codes=["PAK"], total_people_exposed=20, total_buildings_exposed=10, impact_metadata={"summary": "Update", "source": "unit-test"}, ) - # Create existing reply log + # Existing reply for user1 AlertEmailLogFactory.create( - user=self.user1, - subscription=self.subscription1, + user=user, + subscription=subscription, item=update_item, thread=thread, message_id="reply-123", @@ -383,109 +410,26 @@ def test_duplicate_reply(self, mock_send_notification): email_sent_at=timezone.now(), ) - process_email_alert(update_item.id) - - replies = AlertEmailLog.objects.filter(user=self.user1, item=update_item) - self.assertEqual(replies.count(), 1) - mock_send_notification.assert_not_called() - - @mock.patch("alert_system.utils.send_notification") - def test_reply_email_for_daily_limit(self, mock_send_notification): - - correlation_id = "corr-limit-reply" - 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=["NEP"], - total_people_exposed=10, - total_buildings_exposed=5, - impact_metadata={"summary": "Initial", "source": "unit-test"}, - ) - - AlertEmailThreadFactory.create( - user=self.user1, - correlation_id=correlation_id, - root_email_message_id="root-123", - root_message_sent_at=timezone.now(), - ) - - for i in range(self.subscription1.alert_per_day): - other_item = LoadItemFactory.create( - correlation_id=f"corr-other-{i}", - connector=self.connector, - item_eligible=True, - is_past_event=False, - start_datetime=timezone.now(), - event_title=f"Event {i}", - country_codes=["NEP"], - total_people_exposed=10, - total_buildings_exposed=5, - impact_metadata={"summary": "Test", "source": "unit-test"}, - ) - AlertEmailLogFactory.create( - user=self.user1, - subscription=self.subscription1, - item=other_item, - status=AlertEmailLog.Status.SENT, - email_sent_at=timezone.now(), - message_id=str(uuid4()), - ) - - 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", - country_codes=["NEP"], - total_people_exposed=20, - total_buildings_exposed=10, - impact_metadata={"summary": "Update", "source": "unit-test"}, + 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(related_item.id) - - # Should not create reply log - reply_exists = AlertEmailLog.objects.filter(user=self.user1, item=related_item).exists() - self.assertFalse(reply_exists) - mock_send_notification.assert_not_called() - - @mock.patch("alert_system.utils.send_notification") - def test_reply_without_subscription(self, mock_send_notification): - - correlation_id = str(uuid4()) - item = 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=["NEP"], - total_people_exposed=10, - total_buildings_exposed=5, - impact_metadata={"summary": "Initial", "source": "unit-test"}, - ) + process_email_alert(update_item.id) - AlertEmailThreadFactory.create( - user=self.user1, - correlation_id=correlation_id, - root_email_message_id="root-123", - root_message_sent_at=timezone.now(), - ) + # user should NOT get a duplicate + replies_user1 = AlertEmailLog.objects.filter(user=user, item=update_item) + self.assertEqual(replies_user1.count(), 1) - # Delete subscription - self.subscription1.delete() + # user2 should get an email + replies_user2 = AlertEmailLog.objects.filter(user=user2, item=update_item) + self.assertEqual(replies_user2.count(), 1) - process_email_alert(item.id) - reply_exists = AlertEmailLog.objects.filter(item=item).exists() - self.assertFalse(reply_exists) - mock_send_notification.assert_not_called() + mock_send_notification.assert_called_once() # Test command trigger @mock.patch("alert_system.tasks.process_email_alert.delay") diff --git a/assets b/assets index b1b075707..f4d71cf7c 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit b1b075707a4d039ab2cfa37e995e2881a9c44b0e +Subproject commit f4d71cf7c94acc122ce8cd0c90234da2700e8d14 From c1f4a3b9e0015d27c6d1f8ed7255b47a491c6548 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 9 Jan 2026 17:02:04 +0545 Subject: [PATCH 5/6] chore(alert-system): fix migrations --- ...e.py => 0002_alertemailthread_alertemaillog_and_more.py} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename alert_system/migrations/{0004_alertemailthread_alertemaillog_and_more.py => 0002_alertemailthread_alertemaillog_and_more.py} (96%) diff --git a/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py b/alert_system/migrations/0002_alertemailthread_alertemaillog_and_more.py similarity index 96% rename from alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py rename to alert_system/migrations/0002_alertemailthread_alertemaillog_and_more.py index f1eb5ad15..1344f5417 100644 --- a/alert_system/migrations/0004_alertemailthread_alertemaillog_and_more.py +++ b/alert_system/migrations/0002_alertemailthread_alertemaillog_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.26 on 2026-01-08 17:27 +# Generated by Django 4.2.26 on 2026-01-09 11:16 from django.conf import settings from django.db import migrations, models @@ -8,9 +8,9 @@ class Migration(migrations.Migration): dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('notifications', '0016_alertsubscription'), - ('alert_system', '0003_remove_loaditem_related_go_events_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('alert_system', '0001_initial'), ] operations = [ From 903cbfd911293e38922fd0d868802fef109de976 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 9 Jan 2026 17:22:57 +0545 Subject: [PATCH 6/6] fixup! feat(alert-system): feat(alert-system): update alert email task and fix notification tests --- .../management/commands/alert_notification.py | 9 +- api/test_views.py | 143 ++++++++++-------- deploy/helm/ifrcgo-helm/values.yaml | 16 +- main/sentry.py | 8 +- 4 files changed, 94 insertions(+), 82 deletions(-) diff --git a/alert_system/management/commands/alert_notification.py b/alert_system/management/commands/alert_notification.py index 0b3c4e675..826b3e6c4 100644 --- a/alert_system/management/commands/alert_notification.py +++ b/alert_system/management/commands/alert_notification.py @@ -1,15 +1,18 @@ from django.core.management.base import BaseCommand -from sentry_sdk import monitor from alert_system.models import LoadItem from alert_system.tasks import process_email_alert -from main.sentry import SentryMonitor + +# 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) + # @monitor(monitor_slug=SentryMonitor.ALERT_NOTIFICATION) def handle(self, *args, **options): items = LoadItem.objects.filter(item_eligible=True, is_past_event=False) 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/deploy/helm/ifrcgo-helm/values.yaml b/deploy/helm/ifrcgo-helm/values.yaml index 965e0ef35..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: 'alert_notification' - schedule: '0 */2 * * *' + # - 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 a4f4e6e6b..e9d7ee037 100644 --- a/main/sentry.py +++ b/main/sentry.py @@ -129,11 +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 * * *" + # ALERT_NOTIFICATION = "alert_notification", "0 */2 * * *" @staticmethod def load_cron_data() -> typing.List[typing.Tuple[str, str]]: