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>
This commit is contained in:
366
backend/app/services/site_alert_service.py
Normal file
366
backend/app/services/site_alert_service.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user