Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
63df517
feat(notifications): add alert subscription api
sudip-khanal Nov 29, 2025
2c14bc0
chore(app): base setup for alert system app
sandeshit Nov 12, 2025
ae41c85
feat(polling): extraction logic for polling
sandeshit Nov 12, 2025
687cfac
chore(sentry): add cron monitor for tracking
sandeshit Nov 12, 2025
4721fe0
feat(commands): management commands to run different polling tasks
sandeshit Nov 12, 2025
fc78791
refactor(extraction): refactor extraction logic for hazards and impacts.
sandeshit Nov 17, 2025
a201330
feat(extraction): use different classes for different sources
sandeshit Nov 23, 2025
bfad679
feat(filter): add filtration classes and cronjob
sandeshit Nov 23, 2025
7684a2a
feat(etl): refactor existing extraction into ETL.
sandeshit Nov 28, 2025
64714c4
feat(etl): add past events fetcher
sandeshit Dec 7, 2025
c06b27c
chore(alert-system): clean up naming and cronjobs
sandeshit Dec 7, 2025
4fb2585
feat(etl): add past events from GO event table
sandeshit Dec 19, 2025
e613c81
feat(etl): add usgs source
sandeshit Dec 19, 2025
ba87f41
chore(etl): separate past event extraction into different class
sandeshit Dec 23, 2025
47c2e46
feat(etl): change transform logic.
sandeshit Jan 9, 2026
b168741
chore(models): move mappings inside the connector model.
sandeshit Jan 20, 2026
a6d8603
feat(alert-system): Add email alert setup
sudip-khanal Jan 4, 2026
b5ab203
feat(alert-system): feat(alert-system): update alert email task and f…
sudip-khanal Jan 6, 2026
d67a667
chore(alert-system): Update duplicate reply tests for multi-user subs…
sudip-khanal Jan 9, 2026
0b36922
chore(alert-system): fix migrations
sudip-khanal Jan 9, 2026
74fe892
chore(config): add forecasted field
sandeshit Feb 2, 2026
b650377
feat(etl): replace correlation id with guid
sandeshit Feb 2, 2026
f51b0b4
feat(model): Add guid and parent_guid field.
sandeshit Feb 2, 2026
d722d50
feat(alert-system): use parent_guid instead of correlation_id
sudip-khanal Feb 3, 2026
74c4d88
feat(gdacs-cyclone): add source gdacs cyclone
sandeshit Feb 9, 2026
06b9019
chore(usgs-transform): update the transform fields
sandeshit Jan 27, 2026
0b97e8f
feat(notifications): add title field on alert subscription model
sudip-khanal Feb 9, 2026
d1ae94f
fix(loader): fix the base part count for parent guid
sandeshit Feb 13, 2026
d0f8687
chore(monitor): add sentry monitor for cyclone and earthquake
sandeshit Feb 13, 2026
b35e1cf
fix(filter): change the datetime filter
sandeshit Mar 7, 2026
7e46a3b
chore(cyclone): change the forecasted boolean
sandeshit Mar 17, 2026
b165270
feat(etl): remove the forecasted filter from all config.
sandeshit Mar 19, 2026
1938ed0
chore(filter): change the datetime filter.
sandeshit Apr 9, 2026
fa32089
feat(gdacs-flood): New logic for calculating the impacts.
sandeshit Apr 9, 2026
a2af7bc
feat(alert-system): add country/region validation in alert subscripti…
sudip-khanal Apr 20, 2026
90c0f9f
chore(notification): fix notification config
sandeshit May 15, 2026
2acd83a
chore(schema): point to the latest openapi schema
sudip-khanal May 18, 2026
e5796d8
fix: update alert notification corn run time
sudip-khanal May 18, 2026
f5d4041
chore(etl): update the models to include the event_id.
sandeshit May 18, 2026
295e1ec
feat(etl): change etl pipeline to include new url link logic.
sandeshit May 18, 2026
231d067
feat(cyclone): update the episode logic in gdacs cyclone.
sandeshit May 18, 2026
41ff810
feat(alert-system): update related montandon event query
sudip-khanal May 18, 2026
c50eaa9
fix(alert-system): pass recipients as list to send_notification
sudip-khanal May 18, 2026
4e3e9f9
Merge pull request #2741 from IFRCGo/feat/update-alert-email-notifica…
sandeshit May 19, 2026
bda2d6f
feat(notification): make countries field optional on alert subscripti…
sudip-khanal May 20, 2026
1571c96
Merge pull request #2743 from IFRCGo/feat/make-country-optional
sandeshit May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added alert_system/__init__.py
Empty file.
87 changes: 87 additions & 0 deletions alert_system/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from django.contrib import admin

from .models import AlertEmailLog, AlertEmailThread, Connector, ExtractionItem, LoadItem


@admin.register(Connector)
class ConnectorAdmin(admin.ModelAdmin):
list_display = ("id", "type", "last_success_run", "status")
readonly_fields = ("last_success_run",)


@admin.register(ExtractionItem)
class EventAdmin(admin.ModelAdmin):
list_display = (
"stac_id",
"created_at",
"collection",
)
list_filter = ("connector", "collection")
readonly_fields = ("connector",)
search_fields = ("stac_id",)


@admin.register(LoadItem)
class LoadItemAdmin(admin.ModelAdmin):
list_display = (
"id",
"event_title",
"created_at",
"event_id",
"item_eligible",
"is_past_event",
)
list_filter = (
"connector",
"item_eligible",
"is_past_event",
)
readonly_fields = (
"connector",
"item_eligible",
"related_montandon_events",
"related_go_events",
)
search_fields = ("id",)


@admin.register(AlertEmailThread)
class AlertEmailThreadAdmin(admin.ModelAdmin):
list_display = (
"user",
"parent_event_id",
"root_email_message_id",
)
search_fields = (
"parent_event_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",)
6 changes: 6 additions & 0 deletions alert_system/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class AlertSystemConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "alert_system"
75 changes: 75 additions & 0 deletions alert_system/dev_views.py
Original file line number Diff line number Diff line change
@@ -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))
155 changes: 155 additions & 0 deletions alert_system/email_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import logging
import uuid
from typing import Optional

from django.contrib.auth.models import User
from django.db.models import Count
from django.template.loader import render_to_string
from django.utils import timezone

from alert_system.models import AlertEmailLog, AlertEmailThread, LoadItem
from alert_system.utils import get_alert_email_context, get_alert_subscriptions
from notifications.models import AlertSubscription
from notifications.notification import send_notification

logger = logging.getLogger(__name__)


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,
parent_event_id=load_item.parent_event_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 parent event [{load_item.parent_event_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)


def process_email_alert(load_item_id: int) -> None:
load_item = LoadItem.objects.select_related("connector", "connector__dtype").filter(id=load_item_id).first()

if not load_item:
logger.warning(f"LoadItem with ID [{load_item_id}] not found")
return

subscriptions = list(get_alert_subscriptions(load_item))
if not subscriptions:
logger.info(f"No alert subscriptions matched for LoadItem ID [{load_item_id}]")
return

today = timezone.now().date()
user_ids = [sub.user_id for sub in subscriptions]
subscription_ids = [sub.id for sub in subscriptions]

# Daily email counts per user
daily_counts = (
AlertEmailLog.objects.filter(
user_id__in=user_ids,
subscription_id__in=subscription_ids,
status=AlertEmailLog.Status.SENT,
email_sent_at__date=today,
)
.values("user_id", "subscription_id")
.annotate(sent_count=Count("id"))
)
daily_count_map = {(item["user_id"], item["subscription_id"]): item["sent_count"] for item in daily_counts}

# Emails already sent for this item (per user)
already_sent = set(
AlertEmailLog.objects.filter(
user_id__in=user_ids,
subscription_id__in=subscription_ids,
item_id=load_item_id,
status=AlertEmailLog.Status.SENT,
).values_list("user_id", "subscription_id")
)

# Existing threads for this correlation_id
existing_threads = {
thread.user_id: thread
for thread in AlertEmailThread.objects.filter(
parent_event_id=load_item.parent_event_id,
user_id__in=user_ids,
)
}

for subscription in subscriptions:
user = subscription.user
user_id: int = user.id
subscription_id: int = subscription.id

# Reply if this specific user has an existing thread
thread = existing_threads.get(user_id)
is_reply: bool = thread is not None

# Skip if daily alert limit reached
sent_today: int = daily_count_map.get((user_id, subscription_id), 0)
if subscription.alert_per_day and sent_today >= subscription.alert_per_day:
logger.info(f"Daily alert limit reached for user [{user.get_full_name()}]")
continue

# Skip duplicate emails for same item
if (user_id, subscription_id) in already_sent:
logger.info(f"Duplicate alert skipped for user [{user.get_full_name()}] " f"with LoadItem ID [{subscription_id}]")
continue

send_alert_email_notification(load_item=load_item, user=user, subscription=subscription, thread=thread, is_reply=is_reply)
13 changes: 13 additions & 0 deletions alert_system/etl/base/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import Dict, TypedDict


class ExtractionConfig(TypedDict):
event_collection_type: str
hazard_collection_type: str | None
impact_collection_type: str | None

filter_event: Dict | None
filter_hazard: Dict | None
filter_impact: Dict | None

people_exposed_threshold: int
Loading
Loading