Files
the-other-dude/backend/app/services/site_alert_service.py
Jason Staack 124a72582b feat(15-01): add signal history and site alert services, routers, and main.py wiring
- Create signal_history_service with TimescaleDB time_bucket queries for 24h/7d/30d ranges
- Create site_alert_service with full CRUD for rules, events list/resolve, and active count
- Create signal_history router with GET endpoint for time-bucketed signal data
- Create site_alerts router with CRUD endpoints for rules and event management
- Wire both routers into main.py with /api prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:18:02 -05:00

367 lines
11 KiB
Python

"""Site alert service -- CRUD for site/sector alert rules and events.
All functions use raw SQL via the app_user engine (RLS enforced).
Tenant isolation is handled automatically by PostgreSQL RLS policies
once the tenant context is set by the middleware.
"""
import uuid
from typing import Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.site_alert import (
SiteAlertEventListResponse,
SiteAlertEventResponse,
SiteAlertRuleCreate,
SiteAlertRuleListResponse,
SiteAlertRuleResponse,
SiteAlertRuleUpdate,
)
# ---------------------------------------------------------------------------
# Alert Rules CRUD
# ---------------------------------------------------------------------------
async def create_alert_rule(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
data: SiteAlertRuleCreate,
) -> SiteAlertRuleResponse:
"""Create a new site alert rule."""
result = await db.execute(
text("""
INSERT INTO site_alert_rules
(tenant_id, site_id, sector_id, rule_type, name, description,
threshold_value, threshold_unit, enabled)
VALUES
(:tenant_id, :site_id, :sector_id, :rule_type, :name, :description,
:threshold_value, :threshold_unit, :enabled)
RETURNING id, tenant_id, site_id, sector_id, rule_type, name, description,
threshold_value, threshold_unit, enabled, created_at, updated_at
"""),
{
"tenant_id": str(tenant_id),
"site_id": str(site_id),
"sector_id": str(data.sector_id) if data.sector_id else None,
"rule_type": data.rule_type,
"name": data.name,
"description": data.description,
"threshold_value": data.threshold_value,
"threshold_unit": data.threshold_unit,
"enabled": data.enabled,
},
)
row = result.fetchone()
return SiteAlertRuleResponse(
id=row.id,
tenant_id=row.tenant_id,
site_id=row.site_id,
sector_id=row.sector_id,
rule_type=row.rule_type,
name=row.name,
description=row.description,
threshold_value=float(row.threshold_value),
threshold_unit=row.threshold_unit,
enabled=row.enabled,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def list_alert_rules(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: Optional[uuid.UUID] = None,
) -> SiteAlertRuleListResponse:
"""List alert rules for a site, optionally filtered by sector."""
conditions = ["tenant_id = :tenant_id", "site_id = :site_id"]
params: dict = {"tenant_id": str(tenant_id), "site_id": str(site_id)}
if sector_id:
conditions.append("sector_id = :sector_id")
params["sector_id"] = str(sector_id)
where_clause = " AND ".join(conditions)
result = await db.execute(
text(f"""
SELECT id, tenant_id, site_id, sector_id, rule_type, name, description,
threshold_value, threshold_unit, enabled, created_at, updated_at
FROM site_alert_rules
WHERE {where_clause}
ORDER BY created_at DESC
"""),
params,
)
rows = result.fetchall()
items = [
SiteAlertRuleResponse(
id=row.id,
tenant_id=row.tenant_id,
site_id=row.site_id,
sector_id=row.sector_id,
rule_type=row.rule_type,
name=row.name,
description=row.description,
threshold_value=float(row.threshold_value),
threshold_unit=row.threshold_unit,
enabled=row.enabled,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in rows
]
return SiteAlertRuleListResponse(items=items, total=len(items))
async def get_alert_rule(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
rule_id: uuid.UUID,
) -> Optional[SiteAlertRuleResponse]:
"""Fetch a single alert rule by ID."""
result = await db.execute(
text("""
SELECT id, tenant_id, site_id, sector_id, rule_type, name, description,
threshold_value, threshold_unit, enabled, created_at, updated_at
FROM site_alert_rules
WHERE id = :rule_id AND tenant_id = :tenant_id AND site_id = :site_id
"""),
{
"rule_id": str(rule_id),
"tenant_id": str(tenant_id),
"site_id": str(site_id),
},
)
row = result.fetchone()
if not row:
return None
return SiteAlertRuleResponse(
id=row.id,
tenant_id=row.tenant_id,
site_id=row.site_id,
sector_id=row.sector_id,
rule_type=row.rule_type,
name=row.name,
description=row.description,
threshold_value=float(row.threshold_value),
threshold_unit=row.threshold_unit,
enabled=row.enabled,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def update_alert_rule(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
rule_id: uuid.UUID,
data: SiteAlertRuleUpdate,
) -> Optional[SiteAlertRuleResponse]:
"""Update an existing alert rule. Only updates provided fields."""
update_data = data.model_dump(exclude_unset=True)
if not update_data:
return await get_alert_rule(db, tenant_id, site_id, rule_id)
set_clauses = []
params: dict = {
"rule_id": str(rule_id),
"tenant_id": str(tenant_id),
"site_id": str(site_id),
}
for field, value in update_data.items():
if field == "sector_id" and value is not None:
params[field] = str(value)
else:
params[field] = value
set_clauses.append(f"{field} = :{field}")
set_clauses.append("updated_at = now()")
set_clause = ", ".join(set_clauses)
result = await db.execute(
text(f"""
UPDATE site_alert_rules
SET {set_clause}
WHERE id = :rule_id AND tenant_id = :tenant_id AND site_id = :site_id
RETURNING id, tenant_id, site_id, sector_id, rule_type, name, description,
threshold_value, threshold_unit, enabled, created_at, updated_at
"""),
params,
)
row = result.fetchone()
if not row:
return None
return SiteAlertRuleResponse(
id=row.id,
tenant_id=row.tenant_id,
site_id=row.site_id,
sector_id=row.sector_id,
rule_type=row.rule_type,
name=row.name,
description=row.description,
threshold_value=float(row.threshold_value),
threshold_unit=row.threshold_unit,
enabled=row.enabled,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def delete_alert_rule(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
rule_id: uuid.UUID,
) -> bool:
"""Delete an alert rule. Returns True if deleted, False if not found."""
result = await db.execute(
text("""
DELETE FROM site_alert_rules
WHERE id = :rule_id AND tenant_id = :tenant_id AND site_id = :site_id
"""),
{
"rule_id": str(rule_id),
"tenant_id": str(tenant_id),
"site_id": str(site_id),
},
)
return result.rowcount > 0
# ---------------------------------------------------------------------------
# Alert Events
# ---------------------------------------------------------------------------
async def list_alert_events(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
state: Optional[str] = None,
limit: int = 50,
) -> SiteAlertEventListResponse:
"""List alert events for a site, optionally filtered by state."""
conditions = ["tenant_id = :tenant_id", "site_id = :site_id"]
params: dict = {
"tenant_id": str(tenant_id),
"site_id": str(site_id),
"limit": limit,
}
if state:
conditions.append("state = :state")
params["state"] = state
where_clause = " AND ".join(conditions)
result = await db.execute(
text(f"""
SELECT id, tenant_id, site_id, sector_id, rule_id, device_id, link_id,
severity, message, state, consecutive_hits,
triggered_at, resolved_at, resolved_by
FROM site_alert_events
WHERE {where_clause}
ORDER BY triggered_at DESC
LIMIT :limit
"""),
params,
)
rows = result.fetchall()
items = [
SiteAlertEventResponse(
id=row.id,
tenant_id=row.tenant_id,
site_id=row.site_id,
sector_id=row.sector_id,
rule_id=row.rule_id,
device_id=row.device_id,
link_id=row.link_id,
severity=row.severity,
message=row.message,
state=row.state,
consecutive_hits=row.consecutive_hits,
triggered_at=row.triggered_at,
resolved_at=row.resolved_at,
resolved_by=row.resolved_by,
)
for row in rows
]
return SiteAlertEventListResponse(items=items, total=len(items))
async def resolve_alert_event(
db: AsyncSession,
tenant_id: uuid.UUID,
event_id: uuid.UUID,
user_id: uuid.UUID,
) -> Optional[SiteAlertEventResponse]:
"""Resolve an active alert event. Returns None if not found."""
result = await db.execute(
text("""
UPDATE site_alert_events
SET state = 'resolved', resolved_at = now(), resolved_by = :user_id
WHERE id = :event_id AND tenant_id = :tenant_id AND state = 'active'
RETURNING id, tenant_id, site_id, sector_id, rule_id, device_id, link_id,
severity, message, state, consecutive_hits,
triggered_at, resolved_at, resolved_by
"""),
{
"event_id": str(event_id),
"tenant_id": str(tenant_id),
"user_id": str(user_id),
},
)
row = result.fetchone()
if not row:
return None
return SiteAlertEventResponse(
id=row.id,
tenant_id=row.tenant_id,
site_id=row.site_id,
sector_id=row.sector_id,
rule_id=row.rule_id,
device_id=row.device_id,
link_id=row.link_id,
severity=row.severity,
message=row.message,
state=row.state,
consecutive_hits=row.consecutive_hits,
triggered_at=row.triggered_at,
resolved_at=row.resolved_at,
resolved_by=row.resolved_by,
)
async def get_active_event_count(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> int:
"""Count active site alert events for a tenant (notification bell badge)."""
result = await db.execute(
text("""
SELECT count(*) AS cnt
FROM site_alert_events
WHERE tenant_id = :tenant_id AND state = 'active'
"""),
{"tenant_id": str(tenant_id)},
)
row = result.fetchone()
return row.cnt if row else 0