Skip to content

Commit d13d222

Browse files
Merge pull request #2648 from IFRCGo/project/alert-system-workflow
Project:Alert system workflow
2 parents 05d5fea + 924e287 commit d13d222

55 files changed

Lines changed: 4119 additions & 794 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alert_system/__init__.py

Whitespace-only changes.

alert_system/admin.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from django.contrib import admin
2+
3+
from .models import AlertEmailLog, AlertEmailThread, Connector, ExtractionItem, LoadItem
4+
5+
6+
@admin.register(Connector)
7+
class ConnectorAdmin(admin.ModelAdmin):
8+
list_display = ("id", "type", "last_success_run", "status")
9+
readonly_fields = ("last_success_run",)
10+
11+
12+
@admin.register(ExtractionItem)
13+
class EventAdmin(admin.ModelAdmin):
14+
list_display = (
15+
"stac_id",
16+
"created_at",
17+
"collection",
18+
)
19+
list_filter = ("connector", "collection")
20+
readonly_fields = ("connector",)
21+
search_fields = ("stac_id",)
22+
23+
24+
@admin.register(LoadItem)
25+
class LoadItemAdmin(admin.ModelAdmin):
26+
list_display = (
27+
"id",
28+
"event_title",
29+
"created_at",
30+
"event_id",
31+
"item_eligible",
32+
"is_past_event",
33+
)
34+
list_filter = (
35+
"connector",
36+
"item_eligible",
37+
"is_past_event",
38+
)
39+
readonly_fields = (
40+
"connector",
41+
"item_eligible",
42+
"related_montandon_events",
43+
"related_go_events",
44+
)
45+
search_fields = ("id",)
46+
47+
48+
@admin.register(AlertEmailThread)
49+
class AlertEmailThreadAdmin(admin.ModelAdmin):
50+
list_display = (
51+
"user",
52+
"parent_event_id",
53+
"root_email_message_id",
54+
)
55+
search_fields = (
56+
"parent_event_id",
57+
"root_email_message_id",
58+
"user__username",
59+
)
60+
list_select_related = ("user",)
61+
autocomplete_fields = ("user",)
62+
63+
64+
@admin.register(AlertEmailLog)
65+
class AlertEmailLogAdmin(admin.ModelAdmin):
66+
list_display = (
67+
"id",
68+
"message_id",
69+
"status",
70+
)
71+
list_select_related = (
72+
"user",
73+
"subscription",
74+
"item",
75+
"thread",
76+
)
77+
search_fields = (
78+
"user__username",
79+
"message_id",
80+
)
81+
autocomplete_fields = (
82+
"user",
83+
"subscription",
84+
"item",
85+
"thread",
86+
)
87+
list_filter = ("status",)

alert_system/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AlertSystemConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "alert_system"

alert_system/dev_views.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from django.http import HttpResponse
2+
from django.template import loader
3+
from rest_framework import permissions
4+
from rest_framework.views import APIView
5+
6+
7+
class AlertEmailPreview(APIView):
8+
permission_classes = [permissions.IsAuthenticated]
9+
10+
def get(self, request):
11+
type_param = request.GET.get("type")
12+
13+
template_map = {
14+
"alert": "email/alert_system/alert_notification.html",
15+
"alert_reply": "email/alert_system/alert_notification_reply.html",
16+
}
17+
18+
if type_param not in template_map:
19+
valid_values = ", ".join(template_map.keys())
20+
return HttpResponse(
21+
f"Invalid 'type' parameter. Please use one of the following values: {valid_values}.",
22+
)
23+
context_map = {
24+
"alert": {
25+
"user_name": "Test User",
26+
"event_title": "Test Title",
27+
"event_description": "This is a test description for the alert email.",
28+
"start_datetime": "2025-11-28 01:00:00",
29+
"end_datetime": "2025-11-28 01:00:00",
30+
"country_name": [
31+
"Nepal",
32+
],
33+
"total_people_exposed": 1200,
34+
"total_buildings_exposed": 150,
35+
"hazard_types": "Flood",
36+
"related_montandon_events": [
37+
{
38+
"event_title": "Related Event 1",
39+
"total_people_exposed": 100,
40+
"total_buildings_exposed": 300,
41+
"start_datetime": "2025-11-28 01:00:00",
42+
"end_datetime": "2025-11-28 01:00:00",
43+
},
44+
{
45+
"event_title": "Related Event 2",
46+
"total_people_exposed": 200,
47+
"total_buildings_exposed": 500,
48+
"start_datetime": "2025-11-28 01:00:00",
49+
"end_datetime": "2025-11-28 01:00:00",
50+
},
51+
],
52+
"related_go_events": [
53+
"go-event-uuid-1",
54+
"go-event-uuid-2",
55+
],
56+
},
57+
"alert_reply": {
58+
"event_title": "Test Title",
59+
"event_description": "This is a test description for the alert email.",
60+
"start_datetime": "2025-11-28 01:00:00",
61+
"end_datetime": "2025-11-28 01:00:00",
62+
"country_name": [
63+
"Nepal",
64+
],
65+
"total_people_exposed": 1200,
66+
"total_buildings_exposed": 150,
67+
},
68+
}
69+
70+
context = context_map.get(type_param)
71+
if context is None:
72+
return HttpResponse("No context found for the email preview.")
73+
template_file = template_map[type_param]
74+
template = loader.get_template(template_file)
75+
return HttpResponse(template.render(context, request))

alert_system/email_processing.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import logging
2+
import uuid
3+
from typing import Optional
4+
5+
from django.contrib.auth.models import User
6+
from django.db.models import Count
7+
from django.template.loader import render_to_string
8+
from django.utils import timezone
9+
10+
from alert_system.models import AlertEmailLog, AlertEmailThread, LoadItem
11+
from alert_system.utils import get_alert_email_context, get_alert_subscriptions
12+
from notifications.models import AlertSubscription
13+
from notifications.notification import send_notification
14+
15+
logger = logging.getLogger(__name__)
16+
17+
DEFAULT_ALERT_PER_DAY = 100
18+
19+
20+
def send_alert_email_notification(
21+
load_item: LoadItem,
22+
user: User,
23+
subscription: AlertSubscription,
24+
thread: Optional[AlertEmailThread],
25+
is_reply: bool = False,
26+
) -> None:
27+
"""Helper function to send email and create log entry"""
28+
message_id: str = str(uuid.uuid4())
29+
30+
email_log = AlertEmailLog.objects.create(
31+
user=user,
32+
subscription=subscription,
33+
item=load_item,
34+
status=AlertEmailLog.Status.PROCESSING,
35+
message_id=message_id,
36+
thread=thread,
37+
)
38+
39+
try:
40+
if is_reply:
41+
subject = f"Re: Hazard Alert: {load_item.event_title}"
42+
template = "email/alert_system/alert_notification_reply.html"
43+
email_type = "Alert Email Notification Reply"
44+
in_reply_to = thread.root_email_message_id
45+
else:
46+
subject = f"New Hazard Alert: {load_item.event_title}"
47+
template = "email/alert_system/alert_notification.html"
48+
email_type = "Alert Email Notification"
49+
in_reply_to = None
50+
51+
email_context = get_alert_email_context(load_item, user)
52+
email_body = render_to_string(template, email_context)
53+
send_notification(
54+
subject=subject,
55+
recipients=[user.email],
56+
message_id=message_id,
57+
in_reply_to=in_reply_to,
58+
html=email_body,
59+
mailtype=email_type,
60+
)
61+
# Create thread for initial emails
62+
email_log.status = AlertEmailLog.Status.SENT
63+
email_log.email_sent_at = timezone.now()
64+
65+
if not is_reply:
66+
thread, created = AlertEmailThread.objects.get_or_create(
67+
user=user,
68+
parent_event_id=load_item.parent_event_id,
69+
defaults={
70+
"root_email_message_id": message_id,
71+
"root_message_sent_at": timezone.now(),
72+
},
73+
)
74+
email_log.thread = thread
75+
email_log.save(update_fields=["status", "email_sent_at", "thread"])
76+
77+
if created:
78+
logger.info(
79+
f"Alert Email thread created for user [{user.get_full_name()}] "
80+
f"with parent event [{load_item.parent_event_id}]"
81+
)
82+
else:
83+
logger.info(
84+
f"Existing thread found for user [{user.get_full_name()}] " f"with parent event [{load_item.parent_event_id}]"
85+
)
86+
else:
87+
email_log.save(update_fields=["status", "email_sent_at"])
88+
logger.info(f"Alert email sent to [{user.get_full_name()}] for LoadItem ID [{load_item.id}]")
89+
90+
except Exception:
91+
email_log.status = AlertEmailLog.Status.FAILED
92+
email_log.save(update_fields=["status"])
93+
logger.warning(f"Alert email failed for [{user.get_full_name()}] LoadItem ID [{load_item.id}]", exc_info=True)
94+
95+
96+
def process_email_alert(load_item_id: int) -> None:
97+
load_item = LoadItem.objects.select_related("connector", "connector__dtype").filter(id=load_item_id).first()
98+
99+
if not load_item:
100+
logger.warning(f"LoadItem with ID [{load_item_id}] not found")
101+
return
102+
103+
subscriptions = list(get_alert_subscriptions(load_item))
104+
if not subscriptions:
105+
logger.info(f"No alert subscriptions matched for LoadItem ID [{load_item_id}]")
106+
return
107+
108+
today = timezone.now().date()
109+
user_ids = [sub.user_id for sub in subscriptions]
110+
subscription_ids = [sub.id for sub in subscriptions]
111+
112+
# Daily email counts per user
113+
daily_counts = (
114+
AlertEmailLog.objects.filter(
115+
user_id__in=user_ids,
116+
subscription_id__in=subscription_ids,
117+
status=AlertEmailLog.Status.SENT,
118+
email_sent_at__date=today,
119+
)
120+
.values("user_id", "subscription_id")
121+
.annotate(sent_count=Count("id"))
122+
)
123+
daily_count_map = {(item["user_id"], item["subscription_id"]): item["sent_count"] for item in daily_counts}
124+
125+
# Emails already sent for this item (per user)
126+
already_sent = set(
127+
AlertEmailLog.objects.filter(
128+
user_id__in=user_ids,
129+
subscription_id__in=subscription_ids,
130+
item_id=load_item_id,
131+
status=AlertEmailLog.Status.SENT,
132+
).values_list("user_id", "subscription_id")
133+
)
134+
135+
# Existing threads for this correlation_id
136+
existing_threads = {
137+
thread.user_id: thread
138+
for thread in AlertEmailThread.objects.filter(
139+
parent_event_id=load_item.parent_event_id,
140+
user_id__in=user_ids,
141+
)
142+
}
143+
144+
for subscription in subscriptions:
145+
user = subscription.user
146+
user_id: int = user.id
147+
subscription_id: int = subscription.id
148+
149+
# Reply if this specific user has an existing thread
150+
thread = existing_threads.get(user_id)
151+
is_reply: bool = thread is not None
152+
153+
# Skip duplicate emails for same item
154+
if (user_id, subscription_id) in already_sent:
155+
logger.info(f"Duplicate alert skipped for user [{user.get_full_name()}] " f"with LoadItem ID [{load_item_id}]")
156+
continue
157+
158+
# Skip if daily alert limit reached
159+
sent_today: int = daily_count_map.get((user_id, subscription_id), 0)
160+
effective_limit = subscription.alert_per_day or DEFAULT_ALERT_PER_DAY
161+
if sent_today >= effective_limit:
162+
logger.info(f"Daily alert limit reached for user [{user.get_full_name()}]")
163+
continue
164+
165+
send_alert_email_notification(load_item=load_item, user=user, subscription=subscription, thread=thread, is_reply=is_reply)

alert_system/etl/base/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Dict, TypedDict
2+
3+
4+
class ExtractionConfig(TypedDict):
5+
event_collection_type: str
6+
hazard_collection_type: str | None
7+
impact_collection_type: str | None
8+
9+
filter_event: Dict | None
10+
filter_hazard: Dict | None
11+
filter_impact: Dict | None
12+
13+
people_exposed_threshold: int

0 commit comments

Comments
 (0)