Skip to content

Commit e0dc287

Browse files
authored
Merge pull request #105 from Climate-Vision/merge/pr-62
feat: alert delivery worker with email/webhook support (#62, rebased)
2 parents 7b8b85f + e1521b4 commit e0dc287

5 files changed

Lines changed: 476 additions & 1 deletion

File tree

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,16 @@ CLIMATEVISION_LLM_MODEL=claude-haiku-4-5-20251001
5151
# SQLite is used by default. Use an absolute path or a project-relative path.
5252
# For production with high traffic, switch to PostgreSQL and update db.py.
5353
DATABASE_URL=sqlite:///outputs/climatevision.sqlite3
54+
55+
# ---------------------------------------------------------------------------
56+
# SMTP / Alert Delivery (for the alert delivery worker)
57+
# ---------------------------------------------------------------------------
58+
SMTP_HOST=smtp.example.com
59+
SMTP_PORT=587
60+
SMTP_USERNAME=your-email@example.com
61+
SMTP_PASSWORD=your-email-password
62+
SMTP_USE_TLS=true
63+
SMTP_FROM_EMAIL=alerts@climatevision.org
64+
65+
ALERT_DELIVERY_MAX_RETRIES=3
66+
ALERT_DELIVERY_POLL_INTERVAL_SECONDS=60

src/climatevision/api/main.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from pathlib import Path
2020
from typing import Any, Optional, Literal
2121

22+
from contextlib import asynccontextmanager
23+
2224
from pydantic import field_validator
2325

2426
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Header, Query, Depends, Request
@@ -40,6 +42,7 @@
4042
get_subscriptions_for_organization,
4143
create_organization_alert,
4244
get_alerts_for_organization,
45+
get_pending_alerts,
4346
acknowledge_alert,
4447
mark_alert_delivered,
4548
)
@@ -48,6 +51,7 @@
4851
from climatevision.api.auth import require_api_key
4952
from climatevision.governance import explain_prediction, SHAPExplainer
5053
from climatevision.security.api_security import SecurityMiddleware
54+
from climatevision.workers.alert_delivery import AlertDeliveryWorker
5155

5256
logger = logging.getLogger(__name__)
5357

@@ -387,6 +391,18 @@ async def dispatch(self, request: Request, call_next: Any) -> Response:
387391
def create_app() -> FastAPI:
388392
init_db()
389393

394+
# Set up alert delivery worker
395+
alert_worker = AlertDeliveryWorker()
396+
397+
@asynccontextmanager
398+
async def lifespan(_app: FastAPI):
399+
"""Start background workers on app startup, stop on shutdown."""
400+
await alert_worker.start()
401+
logger.info("Alert delivery worker started")
402+
yield
403+
await alert_worker.stop()
404+
logger.info("Alert delivery worker stopped")
405+
390406
app = FastAPI(
391407
title="ClimateVision API",
392408
version="0.2.0",
@@ -402,6 +418,7 @@ def create_app() -> FastAPI:
402418
docs_url="/docs",
403419
redoc_url="/redoc",
404420
openapi_url="/openapi.json",
421+
lifespan=lifespan,
405422
)
406423

407424
app.add_middleware(AuditLogMiddleware)
@@ -1146,6 +1163,39 @@ def mark_alert_as_delivered(
11461163
raise HTTPException(status_code=404, detail="Alert not found")
11471164
return {"success": True, "alert_id": alert_id}
11481165

1166+
@app.get("/api/organizations/{org_id}/alerts/pending")
1167+
def list_pending_alerts(
1168+
org_id: int,
1169+
limit: int = Query(default=50, le=200),
1170+
) -> list[AlertResponse]:
1171+
"""List pending (undelivered) alerts for an organization.
1172+
1173+
This endpoint is designed for monitoring and dashboard use,
1174+
showing alerts that the delivery worker has not yet processed.
1175+
"""
1176+
org = get_organization(org_id)
1177+
if not org:
1178+
raise HTTPException(status_code=404, detail="Organization not found")
1179+
1180+
alerts = get_pending_alerts(max_attempts=3, limit=limit)
1181+
# Filter to this specific organization's alerts
1182+
org_alerts = [a for a in alerts if a["organization_id"] == org_id]
1183+
1184+
return [
1185+
AlertResponse(
1186+
id=alert["id"],
1187+
organization_id=alert["organization_id"],
1188+
alert_type=alert["alert_type"],
1189+
severity=alert["severity"],
1190+
title=alert["title"],
1191+
message=alert["message"],
1192+
delivered=bool(alert["delivered"]),
1193+
acknowledged=bool(alert["acknowledged"]),
1194+
created_at=alert["created_at"],
1195+
)
1196+
for alert in org_alerts[:limit]
1197+
]
1198+
11491199
# ===== Static Files =====
11501200

11511201
# Serve built frontend when dist exists (production mode)

src/climatevision/db.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ def acknowledge_alert(alert_id: int, acknowledged_by: Optional[str] = None) -> b
502502
def mark_alert_delivered(alert_id: int) -> bool:
503503
"""Mark an alert as delivered."""
504504
now = _utc_now_iso()
505-
505+
506506
with get_connection() as conn:
507507
cursor = conn.execute(
508508
"""
@@ -513,3 +513,55 @@ def mark_alert_delivered(alert_id: int) -> bool:
513513
(now, alert_id),
514514
)
515515
return cursor.rowcount > 0
516+
517+
518+
def get_pending_alerts(
519+
max_attempts: int = 3,
520+
limit: int = 100,
521+
) -> list:
522+
"""Get all alerts that have not been delivered and are still within retry limit.
523+
524+
Returns alerts ordered by created_at ascending (oldest first).
525+
"""
526+
import sqlite3
527+
with get_connection() as conn:
528+
return conn.execute(
529+
"""
530+
SELECT * FROM organization_alerts
531+
WHERE delivered = 0 AND delivery_attempts < ?
532+
ORDER BY created_at ASC
533+
LIMIT ?
534+
""",
535+
(max_attempts, limit),
536+
).fetchall()
537+
538+
539+
def increment_delivery_attempt(alert_id: int) -> bool:
540+
"""Increment the delivery attempt counter without marking delivered."""
541+
with get_connection() as conn:
542+
cursor = conn.execute(
543+
"""
544+
UPDATE organization_alerts
545+
SET delivery_attempts = delivery_attempts + 1
546+
WHERE id = ? AND delivered = 0
547+
""",
548+
(alert_id,),
549+
)
550+
return cursor.rowcount > 0
551+
552+
553+
def mark_alert_failed(alert_id: int) -> bool:
554+
"""Mark an alert as permanently failed after exhausting retries."""
555+
now = _utc_now_iso()
556+
with get_connection() as conn:
557+
cursor = conn.execute(
558+
"""
559+
UPDATE organization_alerts
560+
SET delivery_attempts = delivery_attempts + 1,
561+
details = COALESCE(details || ' | ', '') ||
562+
'FAILED: all delivery attempts exhausted at ' || ?
563+
WHERE id = ? AND delivered = 0
564+
""",
565+
(now, alert_id),
566+
)
567+
return cursor.rowcount > 0
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""
2+
Background workers for ClimateVision.
3+
4+
Workers handle async operations that should not block the API:
5+
- Alert delivery (email + webhook)
6+
- Report generation
7+
- Scheduled analysis runs
8+
"""
9+
10+
from climatevision.workers.alert_delivery import AlertDeliveryWorker
11+
12+
__all__ = ["AlertDeliveryWorker"]

0 commit comments

Comments
 (0)