Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ccda602
feat(notifications): add alert subscription api
sudip-khanal Nov 29, 2025
e7c36e8
chore(app): base setup for alert system app
sandeshit Nov 12, 2025
e575510
feat(polling): extraction logic for polling
sandeshit Nov 12, 2025
0be1b1c
chore(sentry): add cron monitor for tracking
sandeshit Nov 12, 2025
5e6cd0c
feat(commands): management commands to run different polling tasks
sandeshit Nov 12, 2025
a5e4e50
refactor(extraction): refactor extraction logic for hazards and impacts.
sandeshit Nov 17, 2025
a42064c
feat(extraction): use different classes for different sources
sandeshit Nov 23, 2025
47618e6
feat(filter): add filtration classes and cronjob
sandeshit Nov 23, 2025
e6a3313
feat(etl): refactor existing extraction into ETL.
sandeshit Nov 28, 2025
6d38f93
feat(etl): add past events fetcher
sandeshit Dec 7, 2025
cb0a5e5
chore(alert-system): clean up naming and cronjobs
sandeshit Dec 7, 2025
f79d527
feat(etl): add past events from GO event table
sandeshit Dec 19, 2025
781ef1d
feat(etl): add usgs source
sandeshit Dec 19, 2025
abe6da0
chore(etl): separate past event extraction into different class
sandeshit Dec 23, 2025
6542f62
feat(etl): change transform logic.
sandeshit Jan 9, 2026
c286872
chore(models): move mappings inside the connector model.
sandeshit Jan 20, 2026
42df9ec
feat(alert-system): Add email alert setup
sudip-khanal Jan 4, 2026
930b312
feat(alert-system): feat(alert-system): update alert email task and f…
sudip-khanal Jan 6, 2026
790b5e0
chore(alert-system): Update duplicate reply tests for multi-user subs…
sudip-khanal Jan 9, 2026
a3e7f0e
chore(alert-system): fix migrations
sudip-khanal Jan 9, 2026
60b54f0
chore(config): add forecasted field
sandeshit Feb 2, 2026
c2bac2c
feat(etl): replace correlation id with guid
sandeshit Feb 2, 2026
bbc03fa
feat(model): Add guid and parent_guid field.
sandeshit Feb 2, 2026
948ed54
feat(alert-system): use parent_guid instead of correlation_id
sudip-khanal Feb 3, 2026
c541365
feat(gdacs-cyclone): add source gdacs cyclone
sandeshit Feb 9, 2026
4b7cf3c
chore(usgs-transform): update the transform fields
sandeshit Jan 27, 2026
eacee5a
feat(notifications): add title field on alert subscription model
sudip-khanal Feb 9, 2026
48d7486
fix(loader): fix the base part count for parent guid
sandeshit Feb 13, 2026
c7b387e
chore(monitor): add sentry monitor for cyclone and earthquake
sandeshit Feb 13, 2026
d0f417b
fix(filter): change the datetime filter
sandeshit Mar 7, 2026
e7543c6
chore(cyclone): change the forecasted boolean
sandeshit Mar 17, 2026
911207f
feat(etl): remove the forecasted filter from all config.
sandeshit Mar 19, 2026
03d9d5c
chore(filter): change the datetime filter.
sandeshit Apr 9, 2026
68acc44
feat(gdacs-flood): New logic for calculating the impacts.
sandeshit Apr 9, 2026
75167ec
feat(alert-system): add country/region validation in alert subscripti…
sudip-khanal Apr 20, 2026
6a4e579
chore(notification): fix notification config
sandeshit May 15, 2026
ce79fc2
chore(schema): point to the latest openapi schema
sudip-khanal May 18, 2026
fc22fee
fix: update alert notification corn run time
sudip-khanal May 18, 2026
8f4f1d5
chore(etl): update the models to include the event_id.
sandeshit May 18, 2026
688d9ea
feat(etl): change etl pipeline to include new url link logic.
sandeshit May 18, 2026
3dbbf67
feat(cyclone): update the episode logic in gdacs cyclone.
sandeshit May 18, 2026
281ef4f
feat(alert-system): update related montandon event query
sudip-khanal May 18, 2026
8ba2cf4
fix(alert-system): pass recipients as list to send_notification
sudip-khanal May 18, 2026
db9b7a4
feat(notification): make countries field optional on alert subscripti…
sudip-khanal May 20, 2026
8b3567e
feat(alert-syetem): add default daily email alert limit
sudip-khanal May 26, 2026
8334eac
chore(assets): update assets to latest commit reference
sandeshit May 26, 2026
70c47bf
chore(schema): point to latest openapi schema
sandeshit May 26, 2026
924e287
Merge pull request #2749 from IFRCGo/chore/update-openapi-schema
sudip-khanal May 26, 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))
165 changes: 165 additions & 0 deletions alert_system/email_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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__)

DEFAULT_ALERT_PER_DAY = 100


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,
)
# Create thread for initial emails
email_log.status = AlertEmailLog.Status.SENT
email_log.email_sent_at = timezone.now()

if not is_reply:
thread, created = AlertEmailThread.objects.get_or_create(
user=user,
parent_event_id=load_item.parent_event_id,
defaults={
"root_email_message_id": message_id,
"root_message_sent_at": timezone.now(),
},
)
email_log.thread = thread
email_log.save(update_fields=["status", "email_sent_at", "thread"])

if created:
logger.info(
f"Alert Email thread created for user [{user.get_full_name()}] "
f"with parent event [{load_item.parent_event_id}]"
)
else:
logger.info(
f"Existing thread found for user [{user.get_full_name()}] " f"with parent event [{load_item.parent_event_id}]"
)
else:
email_log.save(update_fields=["status", "email_sent_at"])
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 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 [{load_item_id}]")
continue

# Skip if daily alert limit reached
sent_today: int = daily_count_map.get((user_id, subscription_id), 0)
effective_limit = subscription.alert_per_day or DEFAULT_ALERT_PER_DAY
if sent_today >= effective_limit:
logger.info(f"Daily alert limit reached for user [{user.get_full_name()}]")
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