Skip to content

Commit e1521b4

Browse files
algojogacorobielin
authored andcommitted
feat: add alert delivery worker with email/webhook support
- Add AlertDeliveryWorker background task with FastAPI lifespan integration - Implement email delivery via SMTP with TLS support - Implement webhook delivery via HTTP POST - Add exponential backoff retry logic (max 3 attempts) - Add DB functions: get_pending_alerts, increment_delivery_attempt, mark_alert_failed - Add GET /api/organizations/{org_id}/alerts/pending endpoint - Update .env.example with SMTP and worker configuration Closes #10
1 parent b3809e3 commit e1521b4

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

@@ -381,6 +385,18 @@ async def dispatch(self, request: Request, call_next: Any) -> Response:
381385
def create_app() -> FastAPI:
382386
init_db()
383387

388+
# Set up alert delivery worker
389+
alert_worker = AlertDeliveryWorker()
390+
391+
@asynccontextmanager
392+
async def lifespan(_app: FastAPI):
393+
"""Start background workers on app startup, stop on shutdown."""
394+
await alert_worker.start()
395+
logger.info("Alert delivery worker started")
396+
yield
397+
await alert_worker.stop()
398+
logger.info("Alert delivery worker stopped")
399+
384400
app = FastAPI(
385401
title="ClimateVision API",
386402
version="0.2.0",
@@ -396,6 +412,7 @@ def create_app() -> FastAPI:
396412
docs_url="/docs",
397413
redoc_url="/redoc",
398414
openapi_url="/openapi.json",
415+
lifespan=lifespan,
399416
)
400417

401418
app.add_middleware(AuditLogMiddleware)
@@ -1143,6 +1160,39 @@ def mark_alert_as_delivered(
11431160
raise HTTPException(status_code=404, detail="Alert not found")
11441161
return {"success": True, "alert_id": alert_id}
11451162

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

11481198
# 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)