Files
the-other-dude/backend/app/routers/alerts.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

1089 lines
35 KiB
Python

"""Alert management API endpoints.
Tenant-scoped routes under /api/tenants/{tenant_id}/ for:
- Alert rules CRUD (list, create, update, delete, toggle)
- Notification channels CRUD (list, create, update, delete, test)
- Alert events listing with pagination and filtering
- Active alert count for nav badge
- Acknowledge and silence actions
- Device-scoped alert listing
RLS enforced via get_db() (app_user engine with tenant context).
RBAC: viewer = read-only (GET); operator and above = write (POST/PUT/PATCH/DELETE).
"""
import base64
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
import re
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, set_tenant_context
from app.middleware.rate_limit import limiter
from app.middleware.rbac import require_scope
from app.middleware.tenant_context import CurrentUser, get_current_user
from app.services.audit_service import log_action
logger = logging.getLogger(__name__)
router = APIRouter(tags=["alerts"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _check_tenant_access(
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
) -> None:
"""Verify the current user is allowed to access the given tenant."""
if current_user.is_super_admin:
await set_tenant_context(db, str(tenant_id))
elif current_user.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant",
)
def _require_write(current_user: CurrentUser) -> None:
"""Raise 403 if user is a viewer (read-only)."""
if current_user.role == "viewer":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Viewers have read-only access.",
)
EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
ALLOWED_METRICS = {
"cpu_load", "memory_used_pct", "disk_used_pct", "temperature",
"signal_strength", "ccq", "client_count",
}
ALLOWED_OPERATORS = {"gt", "lt", "gte", "lte"}
ALLOWED_SEVERITIES = {"critical", "warning", "info"}
# ---------------------------------------------------------------------------
# Request/response models
# ---------------------------------------------------------------------------
class AlertRuleCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
metric: str
operator: str
threshold: float
duration_polls: int = 1
severity: str = "warning"
device_id: Optional[str] = None
group_id: Optional[str] = None
channel_ids: list[str] = []
enabled: bool = True
class AlertRuleUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
metric: str
operator: str
threshold: float
duration_polls: int = 1
severity: str = "warning"
device_id: Optional[str] = None
group_id: Optional[str] = None
channel_ids: list[str] = []
enabled: bool = True
class ChannelCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
channel_type: str # "email", "webhook", or "slack"
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None # plaintext — will be encrypted before storage
smtp_use_tls: bool = False
from_address: Optional[str] = None
to_address: Optional[str] = None
webhook_url: Optional[str] = None
slack_webhook_url: Optional[str] = None
@model_validator(mode="after")
def validate_email_fields(self):
if self.channel_type == "email":
missing = []
if not self.smtp_host:
missing.append("smtp_host")
if not self.smtp_port:
missing.append("smtp_port")
if not self.to_address:
missing.append("to_address")
if missing:
raise ValueError(f"Email channels require: {', '.join(missing)}")
if self.to_address and not EMAIL_REGEX.match(self.to_address):
raise ValueError(f"Invalid email address: {self.to_address}")
if self.from_address and not EMAIL_REGEX.match(self.from_address):
raise ValueError(f"Invalid from address: {self.from_address}")
return self
class ChannelUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
channel_type: str
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None # if None, keep existing
smtp_use_tls: bool = False
from_address: Optional[str] = None
to_address: Optional[str] = None
webhook_url: Optional[str] = None
slack_webhook_url: Optional[str] = None
class SilenceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
duration_minutes: int
# =========================================================================
# ALERT RULES CRUD
# =========================================================================
@router.get(
"/tenants/{tenant_id}/alert-rules",
summary="List all alert rules for tenant",
dependencies=[require_scope("alerts:read")],
)
async def list_alert_rules(
tenant_id: uuid.UUID,
enabled: Optional[bool] = Query(None),
metric: Optional[str] = Query(None),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict[str, Any]]:
await _check_tenant_access(current_user, tenant_id, db)
filters = ["1=1"]
params: dict[str, Any] = {}
if enabled is not None:
filters.append("ar.enabled = :enabled")
params["enabled"] = enabled
if metric:
filters.append("ar.metric = :metric")
params["metric"] = metric
where = " AND ".join(filters)
result = await db.execute(
text(f"""
SELECT ar.id, ar.tenant_id, ar.device_id, ar.group_id,
ar.name, ar.metric, ar.operator, ar.threshold,
ar.duration_polls, ar.severity, ar.enabled, ar.is_default,
ar.created_at,
COALESCE(
(SELECT json_agg(arc.channel_id)
FROM alert_rule_channels arc
WHERE arc.rule_id = ar.id),
'[]'::json
) AS channel_ids
FROM alert_rules ar
WHERE {where}
ORDER BY ar.created_at DESC
"""),
params,
)
rows = result.fetchall()
return [
{
"id": str(row[0]),
"tenant_id": str(row[1]),
"device_id": str(row[2]) if row[2] else None,
"group_id": str(row[3]) if row[3] else None,
"name": row[4],
"metric": row[5],
"operator": row[6],
"threshold": float(row[7]),
"duration_polls": row[8],
"severity": row[9],
"enabled": row[10],
"is_default": row[11],
"created_at": row[12].isoformat() if row[12] else None,
"channel_ids": [str(c) for c in (row[13] if isinstance(row[13], list) else [])],
}
for row in rows
]
@router.post(
"/tenants/{tenant_id}/alert-rules",
summary="Create alert rule",
status_code=status.HTTP_201_CREATED,
)
@limiter.limit("20/minute")
async def create_alert_rule(
request: Request,
tenant_id: uuid.UUID,
body: AlertRuleCreate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
if body.metric not in ALLOWED_METRICS:
raise HTTPException(422, f"metric must be one of: {', '.join(sorted(ALLOWED_METRICS))}")
if body.operator not in ALLOWED_OPERATORS:
raise HTTPException(422, f"operator must be one of: {', '.join(sorted(ALLOWED_OPERATORS))}")
if body.severity not in ALLOWED_SEVERITIES:
raise HTTPException(422, f"severity must be one of: {', '.join(sorted(ALLOWED_SEVERITIES))}")
rule_id = str(uuid.uuid4())
await db.execute(
text("""
INSERT INTO alert_rules
(id, tenant_id, device_id, group_id, name, metric, operator,
threshold, duration_polls, severity, enabled)
VALUES
(CAST(:id AS uuid), CAST(:tenant_id AS uuid),
CAST(:device_id AS uuid), CAST(:group_id AS uuid),
:name, :metric, :operator, :threshold, :duration_polls,
:severity, :enabled)
"""),
{
"id": rule_id,
"tenant_id": str(tenant_id),
"device_id": body.device_id,
"group_id": body.group_id,
"name": body.name,
"metric": body.metric,
"operator": body.operator,
"threshold": body.threshold,
"duration_polls": body.duration_polls,
"severity": body.severity,
"enabled": body.enabled,
},
)
# Create channel associations
for ch_id in body.channel_ids:
await db.execute(
text("""
INSERT INTO alert_rule_channels (rule_id, channel_id)
VALUES (CAST(:rule_id AS uuid), CAST(:channel_id AS uuid))
"""),
{"rule_id": rule_id, "channel_id": ch_id},
)
await db.commit()
try:
await log_action(
db, tenant_id, current_user.user_id, "alert_rule_create",
resource_type="alert_rule", resource_id=rule_id,
details={"name": body.name, "metric": body.metric, "severity": body.severity},
)
except Exception:
pass
return {
"id": rule_id,
"tenant_id": str(tenant_id),
"name": body.name,
"metric": body.metric,
"operator": body.operator,
"threshold": body.threshold,
"duration_polls": body.duration_polls,
"severity": body.severity,
"enabled": body.enabled,
"channel_ids": body.channel_ids,
}
@router.put(
"/tenants/{tenant_id}/alert-rules/{rule_id}",
summary="Update alert rule",
)
@limiter.limit("20/minute")
async def update_alert_rule(
request: Request,
tenant_id: uuid.UUID,
rule_id: uuid.UUID,
body: AlertRuleUpdate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
if body.metric not in ALLOWED_METRICS:
raise HTTPException(422, f"metric must be one of: {', '.join(sorted(ALLOWED_METRICS))}")
if body.operator not in ALLOWED_OPERATORS:
raise HTTPException(422, f"operator must be one of: {', '.join(sorted(ALLOWED_OPERATORS))}")
if body.severity not in ALLOWED_SEVERITIES:
raise HTTPException(422, f"severity must be one of: {', '.join(sorted(ALLOWED_SEVERITIES))}")
result = await db.execute(
text("""
UPDATE alert_rules
SET name = :name, metric = :metric, operator = :operator,
threshold = :threshold, duration_polls = :duration_polls,
severity = :severity, device_id = CAST(:device_id AS uuid),
group_id = CAST(:group_id AS uuid), enabled = :enabled
WHERE id = CAST(:rule_id AS uuid)
RETURNING id
"""),
{
"rule_id": str(rule_id),
"name": body.name,
"metric": body.metric,
"operator": body.operator,
"threshold": body.threshold,
"duration_polls": body.duration_polls,
"severity": body.severity,
"device_id": body.device_id,
"group_id": body.group_id,
"enabled": body.enabled,
},
)
if not result.fetchone():
raise HTTPException(404, "Alert rule not found")
# Replace channel associations
await db.execute(
text("DELETE FROM alert_rule_channels WHERE rule_id = CAST(:rule_id AS uuid)"),
{"rule_id": str(rule_id)},
)
for ch_id in body.channel_ids:
await db.execute(
text("""
INSERT INTO alert_rule_channels (rule_id, channel_id)
VALUES (CAST(:rule_id AS uuid), CAST(:channel_id AS uuid))
"""),
{"rule_id": str(rule_id), "channel_id": ch_id},
)
await db.commit()
try:
await log_action(
db, tenant_id, current_user.user_id, "alert_rule_update",
resource_type="alert_rule", resource_id=str(rule_id),
details={"name": body.name, "metric": body.metric, "severity": body.severity},
)
except Exception:
pass
return {
"id": str(rule_id),
"name": body.name,
"metric": body.metric,
"operator": body.operator,
"threshold": body.threshold,
"duration_polls": body.duration_polls,
"severity": body.severity,
"enabled": body.enabled,
"channel_ids": body.channel_ids,
}
@router.delete(
"/tenants/{tenant_id}/alert-rules/{rule_id}",
summary="Delete alert rule",
status_code=status.HTTP_204_NO_CONTENT,
)
@limiter.limit("5/minute")
async def delete_alert_rule(
request: Request,
tenant_id: uuid.UUID,
rule_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
# Prevent deletion of default rules
check = await db.execute(
text("SELECT is_default FROM alert_rules WHERE id = CAST(:id AS uuid)"),
{"id": str(rule_id)},
)
row = check.fetchone()
if not row:
raise HTTPException(404, "Alert rule not found")
if row[0]:
raise HTTPException(422, "Cannot delete default alert rules. Disable them instead.")
await db.execute(
text("DELETE FROM alert_rules WHERE id = CAST(:id AS uuid)"),
{"id": str(rule_id)},
)
await db.commit()
try:
await log_action(
db, tenant_id, current_user.user_id, "alert_rule_delete",
resource_type="alert_rule", resource_id=str(rule_id),
)
except Exception:
pass
@router.patch(
"/tenants/{tenant_id}/alert-rules/{rule_id}/toggle",
summary="Toggle alert rule enabled/disabled",
)
@limiter.limit("20/minute")
async def toggle_alert_rule(
request: Request,
tenant_id: uuid.UUID,
rule_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
result = await db.execute(
text("""
UPDATE alert_rules SET enabled = NOT enabled
WHERE id = CAST(:id AS uuid)
RETURNING id, enabled
"""),
{"id": str(rule_id)},
)
row = result.fetchone()
if not row:
raise HTTPException(404, "Alert rule not found")
await db.commit()
return {"id": str(row[0]), "enabled": row[1]}
# =========================================================================
# NOTIFICATION CHANNELS CRUD
# =========================================================================
class SMTPTestRequest(BaseModel):
smtp_host: str
smtp_port: int = 587
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
smtp_use_tls: bool = False
from_address: str = "alerts@example.com"
to_address: str
@router.post(
"/tenants/{tenant_id}/notification-channels/test-smtp",
summary="Test SMTP settings before creating a channel",
)
async def test_channel_smtp(
tenant_id: uuid.UUID,
data: SMTPTestRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
"""Test SMTP settings before creating a channel."""
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
from app.services.email_service import SMTPConfig, send_test_email
config = SMTPConfig(
host=data.smtp_host,
port=data.smtp_port,
user=data.smtp_user,
password=data.smtp_password,
use_tls=data.smtp_use_tls,
from_address=data.from_address,
)
result = await send_test_email(data.to_address, config)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["message"])
return result
@router.get(
"/tenants/{tenant_id}/notification-channels",
summary="List notification channels for tenant",
dependencies=[require_scope("alerts:read")],
)
async def list_notification_channels(
tenant_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict[str, Any]]:
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
text("""
SELECT id, tenant_id, name, channel_type,
smtp_host, smtp_port, smtp_user, smtp_use_tls,
from_address, to_address, webhook_url,
created_at, slack_webhook_url
FROM notification_channels
ORDER BY created_at DESC
""")
)
return [
{
"id": str(row[0]),
"tenant_id": str(row[1]),
"name": row[2],
"channel_type": row[3],
"smtp_host": row[4],
"smtp_port": row[5],
"smtp_user": row[6],
"smtp_use_tls": row[7],
"from_address": row[8],
"to_address": row[9],
"webhook_url": row[10],
"created_at": row[11].isoformat() if row[11] else None,
"slack_webhook_url": row[12],
}
for row in result.fetchall()
]
@router.post(
"/tenants/{tenant_id}/notification-channels",
summary="Create notification channel",
status_code=status.HTTP_201_CREATED,
)
@limiter.limit("20/minute")
async def create_notification_channel(
request: Request,
tenant_id: uuid.UUID,
body: ChannelCreate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
if body.channel_type not in ("email", "webhook", "slack"):
raise HTTPException(422, "channel_type must be 'email', 'webhook', or 'slack'")
channel_id = str(uuid.uuid4())
from app.services.crypto import encrypt_credentials_transit
# Encrypt SMTP password via Transit if provided
encrypted_password_transit = None
if body.smtp_password:
encrypted_password_transit = await encrypt_credentials_transit(
body.smtp_password, str(tenant_id),
)
await db.execute(
text("""
INSERT INTO notification_channels
(id, tenant_id, name, channel_type, smtp_host, smtp_port,
smtp_user, smtp_password_transit, smtp_use_tls, from_address,
to_address, webhook_url, slack_webhook_url)
VALUES
(CAST(:id AS uuid), CAST(:tenant_id AS uuid),
:name, :channel_type, :smtp_host, :smtp_port,
:smtp_user, :smtp_password_transit, :smtp_use_tls,
:from_address, :to_address, :webhook_url,
:slack_webhook_url)
"""),
{
"id": channel_id,
"tenant_id": str(tenant_id),
"name": body.name,
"channel_type": body.channel_type,
"smtp_host": body.smtp_host,
"smtp_port": body.smtp_port,
"smtp_user": body.smtp_user,
"smtp_password_transit": encrypted_password_transit,
"smtp_use_tls": body.smtp_use_tls,
"from_address": body.from_address,
"to_address": body.to_address,
"webhook_url": body.webhook_url,
"slack_webhook_url": body.slack_webhook_url,
},
)
await db.commit()
return {
"id": channel_id,
"tenant_id": str(tenant_id),
"name": body.name,
"channel_type": body.channel_type,
"smtp_host": body.smtp_host,
"smtp_port": body.smtp_port,
"smtp_user": body.smtp_user,
"smtp_use_tls": body.smtp_use_tls,
"from_address": body.from_address,
"to_address": body.to_address,
"webhook_url": body.webhook_url,
"slack_webhook_url": body.slack_webhook_url,
}
@router.put(
"/tenants/{tenant_id}/notification-channels/{channel_id}",
summary="Update notification channel",
)
@limiter.limit("20/minute")
async def update_notification_channel(
request: Request,
tenant_id: uuid.UUID,
channel_id: uuid.UUID,
body: ChannelUpdate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
if body.channel_type not in ("email", "webhook", "slack"):
raise HTTPException(422, "channel_type must be 'email', 'webhook', or 'slack'")
from app.services.crypto import encrypt_credentials_transit
# Build SET clauses dynamically based on which secrets are provided
set_parts = [
"name = :name", "channel_type = :channel_type",
"smtp_host = :smtp_host", "smtp_port = :smtp_port",
"smtp_user = :smtp_user", "smtp_use_tls = :smtp_use_tls",
"from_address = :from_address", "to_address = :to_address",
"webhook_url = :webhook_url",
"slack_webhook_url = :slack_webhook_url",
]
params: dict[str, Any] = {
"id": str(channel_id),
"name": body.name,
"channel_type": body.channel_type,
"smtp_host": body.smtp_host,
"smtp_port": body.smtp_port,
"smtp_user": body.smtp_user,
"smtp_use_tls": body.smtp_use_tls,
"from_address": body.from_address,
"to_address": body.to_address,
"webhook_url": body.webhook_url,
"slack_webhook_url": body.slack_webhook_url,
}
if body.smtp_password:
set_parts.append("smtp_password_transit = :smtp_password_transit")
params["smtp_password_transit"] = await encrypt_credentials_transit(
body.smtp_password, str(tenant_id),
)
# Clear legacy column
set_parts.append("smtp_password = NULL")
set_clause = ", ".join(set_parts)
result = await db.execute(
text(f"""
UPDATE notification_channels
SET {set_clause}
WHERE id = CAST(:id AS uuid)
RETURNING id
"""),
params,
)
if not result.fetchone():
raise HTTPException(404, "Notification channel not found")
await db.commit()
return {
"id": str(channel_id),
"name": body.name,
"channel_type": body.channel_type,
"smtp_host": body.smtp_host,
"smtp_port": body.smtp_port,
"smtp_user": body.smtp_user,
"smtp_use_tls": body.smtp_use_tls,
"from_address": body.from_address,
"to_address": body.to_address,
"webhook_url": body.webhook_url,
"slack_webhook_url": body.slack_webhook_url,
}
@router.delete(
"/tenants/{tenant_id}/notification-channels/{channel_id}",
summary="Delete notification channel",
status_code=status.HTTP_204_NO_CONTENT,
)
@limiter.limit("5/minute")
async def delete_notification_channel(
request: Request,
tenant_id: uuid.UUID,
channel_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
result = await db.execute(
text("DELETE FROM notification_channels WHERE id = CAST(:id AS uuid) RETURNING id"),
{"id": str(channel_id)},
)
if not result.fetchone():
raise HTTPException(404, "Notification channel not found")
await db.commit()
@router.post(
"/tenants/{tenant_id}/notification-channels/{channel_id}/test",
summary="Send test notification via channel",
)
@limiter.limit("5/minute")
async def test_notification_channel(
request: Request,
tenant_id: uuid.UUID,
channel_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
# Fetch channel as dict for notification_service
result = await db.execute(
text("""
SELECT id, tenant_id, name, channel_type,
smtp_host, smtp_port, smtp_user, smtp_password,
smtp_use_tls, from_address, to_address,
webhook_url, smtp_password_transit,
slack_webhook_url
FROM notification_channels
WHERE id = CAST(:id AS uuid)
"""),
{"id": str(channel_id)},
)
row = result.fetchone()
if not row:
raise HTTPException(404, "Notification channel not found")
channel = {
"id": str(row[0]),
"tenant_id": str(row[1]),
"name": row[2],
"channel_type": row[3],
"smtp_host": row[4],
"smtp_port": row[5],
"smtp_user": row[6],
"smtp_password": row[7],
"smtp_use_tls": row[8],
"from_address": row[9],
"to_address": row[10],
"webhook_url": row[11],
"smtp_password_transit": row[12],
"slack_webhook_url": row[13],
}
from app.services.notification_service import send_test_notification
try:
success = await send_test_notification(channel)
if success:
return {"status": "ok", "message": "Test notification sent successfully"}
else:
raise HTTPException(422, "Test notification delivery failed")
except HTTPException:
raise
except Exception as exc:
raise HTTPException(422, f"Test notification failed: {str(exc)}")
# =========================================================================
# ALERT EVENTS (read + actions)
# =========================================================================
@router.get(
"/tenants/{tenant_id}/alerts",
summary="List alert events with filtering and pagination",
dependencies=[require_scope("alerts:read")],
)
async def list_alerts(
tenant_id: uuid.UUID,
alert_status: Optional[str] = Query(None, alias="status"),
severity: Optional[str] = Query(None),
device_id: Optional[str] = Query(None),
rule_id: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
filters = ["1=1"]
params: dict[str, Any] = {}
if alert_status:
filters.append("ae.status = :status")
params["status"] = alert_status
if severity:
filters.append("ae.severity = :severity")
params["severity"] = severity
if device_id:
filters.append("ae.device_id = CAST(:device_id AS uuid)")
params["device_id"] = device_id
if rule_id:
filters.append("ae.rule_id = CAST(:rule_id AS uuid)")
params["rule_id"] = rule_id
if start_date:
filters.append("ae.fired_at >= CAST(:start_date AS timestamptz)")
params["start_date"] = start_date
if end_date:
filters.append("ae.fired_at <= CAST(:end_date AS timestamptz)")
params["end_date"] = end_date
where = " AND ".join(filters)
offset = (page - 1) * per_page
# Get total count
count_result = await db.execute(
text(f"SELECT COUNT(*) FROM alert_events ae WHERE {where}"),
params,
)
total = count_result.scalar() or 0
# Get page of results with device hostname and rule name
result = await db.execute(
text(f"""
SELECT ae.id, ae.rule_id, ae.device_id, ae.tenant_id,
ae.status, ae.severity, ae.metric, ae.value,
ae.threshold, ae.message, ae.is_flapping,
ae.acknowledged_at, ae.silenced_until,
ae.fired_at, ae.resolved_at,
d.hostname AS device_hostname,
ar.name AS rule_name
FROM alert_events ae
LEFT JOIN devices d ON d.id = ae.device_id
LEFT JOIN alert_rules ar ON ar.id = ae.rule_id
WHERE {where}
ORDER BY ae.fired_at DESC
LIMIT :limit OFFSET :offset
"""),
{**params, "limit": per_page, "offset": offset},
)
items = [
{
"id": str(row[0]),
"rule_id": str(row[1]) if row[1] else None,
"device_id": str(row[2]),
"tenant_id": str(row[3]),
"status": row[4],
"severity": row[5],
"metric": row[6],
"value": float(row[7]) if row[7] is not None else None,
"threshold": float(row[8]) if row[8] is not None else None,
"message": row[9],
"is_flapping": row[10],
"acknowledged_at": row[11].isoformat() if row[11] else None,
"silenced_until": row[12].isoformat() if row[12] else None,
"fired_at": row[13].isoformat() if row[13] else None,
"resolved_at": row[14].isoformat() if row[14] else None,
"device_hostname": row[15],
"rule_name": row[16],
}
for row in result.fetchall()
]
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
}
@router.get(
"/tenants/{tenant_id}/alerts/active-count",
summary="Get count of active (firing) alerts for nav badge",
dependencies=[require_scope("alerts:read")],
)
async def get_active_alert_count(
tenant_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, int]:
await _check_tenant_access(current_user, tenant_id, db)
result = await db.execute(
text("""
SELECT COUNT(*) FROM alert_events
WHERE status = 'firing'
AND resolved_at IS NULL
AND (silenced_until IS NULL OR silenced_until < NOW())
""")
)
count = result.scalar() or 0
return {"count": count}
@router.post(
"/tenants/{tenant_id}/alerts/{alert_id}/acknowledge",
summary="Acknowledge an active alert",
)
@limiter.limit("20/minute")
async def acknowledge_alert(
request: Request,
tenant_id: uuid.UUID,
alert_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
result = await db.execute(
text("""
UPDATE alert_events
SET acknowledged_at = NOW(), acknowledged_by = CAST(:user_id AS uuid)
WHERE id = CAST(:id AS uuid)
RETURNING id
"""),
{"id": str(alert_id), "user_id": str(current_user.user_id)},
)
if not result.fetchone():
raise HTTPException(404, "Alert not found")
await db.commit()
return {"status": "ok", "message": "Alert acknowledged"}
@router.post(
"/tenants/{tenant_id}/alerts/{alert_id}/silence",
summary="Silence an alert for a specified duration",
)
@limiter.limit("20/minute")
async def silence_alert(
request: Request,
tenant_id: uuid.UUID,
alert_id: uuid.UUID,
body: SilenceRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
await _check_tenant_access(current_user, tenant_id, db)
_require_write(current_user)
if body.duration_minutes < 1:
raise HTTPException(422, "duration_minutes must be at least 1")
result = await db.execute(
text("""
UPDATE alert_events
SET silenced_until = NOW() + (:minutes || ' minutes')::interval
WHERE id = CAST(:id AS uuid)
RETURNING id
"""),
{"id": str(alert_id), "minutes": str(body.duration_minutes)},
)
if not result.fetchone():
raise HTTPException(404, "Alert not found")
await db.commit()
return {"status": "ok", "message": f"Alert silenced for {body.duration_minutes} minutes"}
@router.get(
"/tenants/{tenant_id}/devices/{device_id}/alerts",
summary="List alerts for a specific device",
dependencies=[require_scope("alerts:read")],
)
async def list_device_alerts(
tenant_id: uuid.UUID,
device_id: uuid.UUID,
alert_status: Optional[str] = Query(None, alias="status"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict[str, Any]:
await _check_tenant_access(current_user, tenant_id, db)
filters = ["ae.device_id = CAST(:device_id AS uuid)"]
params: dict[str, Any] = {"device_id": str(device_id)}
if alert_status:
filters.append("ae.status = :status")
params["status"] = alert_status
where = " AND ".join(filters)
offset = (page - 1) * per_page
count_result = await db.execute(
text(f"SELECT COUNT(*) FROM alert_events ae WHERE {where}"),
params,
)
total = count_result.scalar() or 0
result = await db.execute(
text(f"""
SELECT ae.id, ae.rule_id, ae.device_id, ae.tenant_id,
ae.status, ae.severity, ae.metric, ae.value,
ae.threshold, ae.message, ae.is_flapping,
ae.acknowledged_at, ae.silenced_until,
ae.fired_at, ae.resolved_at,
ar.name AS rule_name
FROM alert_events ae
LEFT JOIN alert_rules ar ON ar.id = ae.rule_id
WHERE {where}
ORDER BY ae.fired_at DESC
LIMIT :limit OFFSET :offset
"""),
{**params, "limit": per_page, "offset": offset},
)
items = [
{
"id": str(row[0]),
"rule_id": str(row[1]) if row[1] else None,
"device_id": str(row[2]),
"tenant_id": str(row[3]),
"status": row[4],
"severity": row[5],
"metric": row[6],
"value": float(row[7]) if row[7] is not None else None,
"threshold": float(row[8]) if row[8] is not None else None,
"message": row[9],
"is_flapping": row[10],
"acknowledged_at": row[11].isoformat() if row[11] else None,
"silenced_until": row[12].isoformat() if row[12] else None,
"fired_at": row[13].isoformat() if row[13] else None,
"resolved_at": row[14].isoformat() if row[14] else None,
"rule_name": row[15],
}
for row in result.fetchall()
]
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
}