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:
@@ -465,6 +465,8 @@ def create_app() -> FastAPI:
|
||||
from app.routers.sites import router as sites_router
|
||||
from app.routers.links import router as links_router
|
||||
from app.routers.sectors import router as sectors_router
|
||||
from app.routers.signal_history import router as signal_history_router
|
||||
from app.routers.site_alerts import router as site_alerts_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
app.include_router(tenants_router, prefix="/api")
|
||||
@@ -497,6 +499,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(sites_router, prefix="/api")
|
||||
app.include_router(links_router, prefix="/api")
|
||||
app.include_router(sectors_router, prefix="/api")
|
||||
app.include_router(signal_history_router, prefix="/api")
|
||||
app.include_router(site_alerts_router, prefix="/api")
|
||||
|
||||
# Health check endpoints
|
||||
@app.get("/health", tags=["health"])
|
||||
|
||||
52
backend/app/routers/signal_history.py
Normal file
52
backend/app/routers/signal_history.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Signal history API endpoint.
|
||||
|
||||
Routes: /api/tenants/{tenant_id}/devices/{device_id}/signal-history
|
||||
|
||||
RBAC:
|
||||
- viewer: GET (read-only)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
from app.routers.devices import _check_tenant_access
|
||||
from app.schemas.site_alert import SignalHistoryResponse
|
||||
from app.services import signal_history_service
|
||||
|
||||
router = APIRouter(tags=["signal-history"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/devices/{device_id}/signal-history",
|
||||
response_model=SignalHistoryResponse,
|
||||
summary="Get signal strength history for a wireless client",
|
||||
)
|
||||
async def get_signal_history(
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
mac_address: str = Query(..., description="Client MAC address to query history for"),
|
||||
range: str = Query("7d", description="Time range: 24h, 7d, or 30d"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SignalHistoryResponse:
|
||||
"""Get time-bucketed signal strength history for a wireless client on a device."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
|
||||
if range not in ("24h", "7d", "30d"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="range must be one of: 24h, 7d, 30d",
|
||||
)
|
||||
|
||||
return await signal_history_service.get_signal_history(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
device_id=device_id,
|
||||
mac_address=mac_address,
|
||||
range=range,
|
||||
)
|
||||
219
backend/app/routers/site_alerts.py
Normal file
219
backend/app/routers/site_alerts.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Site alert rules and events API endpoints.
|
||||
|
||||
Routes:
|
||||
/api/tenants/{tenant_id}/sites/{site_id}/alert-rules (CRUD)
|
||||
/api/tenants/{tenant_id}/sites/{site_id}/alert-events (list)
|
||||
/api/tenants/{tenant_id}/alert-events/{event_id}/resolve (resolve)
|
||||
/api/tenants/{tenant_id}/alert-events/count (active count for bell badge)
|
||||
|
||||
RBAC:
|
||||
- viewer: GET endpoints (read-only)
|
||||
- operator: POST, PUT, DELETE, resolve (write)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.middleware.rbac import require_operator_or_above
|
||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||
from app.routers.devices import _check_tenant_access
|
||||
from app.schemas.site_alert import (
|
||||
SiteAlertEventListResponse,
|
||||
SiteAlertEventResponse,
|
||||
SiteAlertRuleCreate,
|
||||
SiteAlertRuleListResponse,
|
||||
SiteAlertRuleResponse,
|
||||
SiteAlertRuleUpdate,
|
||||
)
|
||||
from app.services import site_alert_service
|
||||
|
||||
router = APIRouter(tags=["site-alerts"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alert Rules CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/sites/{site_id}/alert-rules",
|
||||
response_model=SiteAlertRuleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a site alert rule",
|
||||
dependencies=[Depends(require_operator_or_above)],
|
||||
)
|
||||
async def create_alert_rule(
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
data: SiteAlertRuleCreate,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteAlertRuleResponse:
|
||||
"""Create a new site/sector alert rule. Requires operator role or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
return await site_alert_service.create_alert_rule(
|
||||
db=db, tenant_id=tenant_id, site_id=site_id, data=data
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/sites/{site_id}/alert-rules",
|
||||
response_model=SiteAlertRuleListResponse,
|
||||
summary="List site alert rules",
|
||||
)
|
||||
async def list_alert_rules(
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
sector_id: Optional[uuid.UUID] = Query(None, description="Filter by sector"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteAlertRuleListResponse:
|
||||
"""List alert rules for a site, optionally filtered by sector. Viewer role and above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
return await site_alert_service.list_alert_rules(
|
||||
db=db, tenant_id=tenant_id, site_id=site_id, sector_id=sector_id
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/sites/{site_id}/alert-rules/{rule_id}",
|
||||
response_model=SiteAlertRuleResponse,
|
||||
summary="Get a site alert rule",
|
||||
)
|
||||
async def get_alert_rule(
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteAlertRuleResponse:
|
||||
"""Get a single site alert rule by ID. Viewer role and above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
result = await site_alert_service.get_alert_rule(
|
||||
db=db, tenant_id=tenant_id, site_id=site_id, rule_id=rule_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Alert rule not found"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tenants/{tenant_id}/sites/{site_id}/alert-rules/{rule_id}",
|
||||
response_model=SiteAlertRuleResponse,
|
||||
summary="Update a site alert rule",
|
||||
dependencies=[Depends(require_operator_or_above)],
|
||||
)
|
||||
async def update_alert_rule(
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
data: SiteAlertRuleUpdate,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteAlertRuleResponse:
|
||||
"""Update a site alert rule. Requires operator role or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
result = await site_alert_service.update_alert_rule(
|
||||
db=db, tenant_id=tenant_id, site_id=site_id, rule_id=rule_id, data=data
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Alert rule not found"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tenants/{tenant_id}/sites/{site_id}/alert-rules/{rule_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a site alert rule",
|
||||
dependencies=[Depends(require_operator_or_above)],
|
||||
)
|
||||
async def delete_alert_rule(
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a site alert rule. Requires operator role or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
deleted = await site_alert_service.delete_alert_rule(
|
||||
db=db, tenant_id=tenant_id, site_id=site_id, rule_id=rule_id
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Alert rule not found"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alert Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/sites/{site_id}/alert-events",
|
||||
response_model=SiteAlertEventListResponse,
|
||||
summary="List site alert events",
|
||||
)
|
||||
async def list_alert_events(
|
||||
tenant_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
state: Optional[str] = Query(None, description="Filter by state (active, resolved)"),
|
||||
limit: int = Query(50, ge=1, le=200, description="Max events to return"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteAlertEventListResponse:
|
||||
"""List alert events for a site. Viewer role and above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
return await site_alert_service.list_alert_events(
|
||||
db=db, tenant_id=tenant_id, site_id=site_id, state=state, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/alert-events/{event_id}/resolve",
|
||||
response_model=SiteAlertEventResponse,
|
||||
summary="Resolve a site alert event",
|
||||
dependencies=[Depends(require_operator_or_above)],
|
||||
)
|
||||
async def resolve_alert_event(
|
||||
tenant_id: uuid.UUID,
|
||||
event_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SiteAlertEventResponse:
|
||||
"""Resolve an active alert event. Requires operator role or above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
result = await site_alert_service.resolve_alert_event(
|
||||
db=db, tenant_id=tenant_id, event_id=event_id, user_id=current_user.user_id
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Alert event not found or already resolved",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants/{tenant_id}/alert-events/count",
|
||||
summary="Get active alert event count",
|
||||
)
|
||||
async def get_active_event_count(
|
||||
tenant_id: uuid.UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get count of active site alert events for notification bell badge. Viewer role and above."""
|
||||
await _check_tenant_access(current_user, tenant_id, db)
|
||||
count = await site_alert_service.get_active_event_count(db=db, tenant_id=tenant_id)
|
||||
return {"count": count}
|
||||
79
backend/app/services/signal_history_service.py
Normal file
79
backend/app/services/signal_history_service.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Signal history service -- time-bucketed signal strength queries.
|
||||
|
||||
Uses raw SQL with TimescaleDB time_bucket() for efficient time-series aggregation.
|
||||
All queries run via the app_user engine (RLS enforced).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.site_alert import SignalHistoryPoint, SignalHistoryResponse
|
||||
|
||||
# Mapping of range parameter to (time_bucket interval, lookback interval)
|
||||
RANGE_CONFIG = {
|
||||
"24h": ("5 minutes", "24 hours"),
|
||||
"7d": ("1 hour", "7 days"),
|
||||
"30d": ("4 hours", "30 days"),
|
||||
}
|
||||
|
||||
|
||||
async def get_signal_history(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
device_id: uuid.UUID,
|
||||
mac_address: str,
|
||||
range: str = "7d",
|
||||
) -> SignalHistoryResponse:
|
||||
"""Query time-bucketed signal history for a specific client MAC on a device.
|
||||
|
||||
Args:
|
||||
db: Database session (app_user with RLS).
|
||||
tenant_id: Tenant UUID for RLS context.
|
||||
device_id: Device UUID (the AP the client connects to).
|
||||
mac_address: Client MAC address to query history for.
|
||||
range: Time range -- "24h", "7d", or "30d".
|
||||
|
||||
Returns:
|
||||
SignalHistoryResponse with time-bucketed signal avg/min/max.
|
||||
"""
|
||||
bucket_interval, lookback = RANGE_CONFIG.get(range, RANGE_CONFIG["7d"])
|
||||
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
time_bucket(:bucket_interval, wr.time) AS bucket,
|
||||
avg(wr.signal_strength)::int AS signal_avg,
|
||||
min(wr.signal_strength) AS signal_min,
|
||||
max(wr.signal_strength) AS signal_max
|
||||
FROM wireless_registrations wr
|
||||
WHERE wr.mac_address = :mac_address
|
||||
AND wr.device_id = :device_id
|
||||
AND wr.tenant_id = :tenant_id
|
||||
AND wr.time > now() - :lookback::interval
|
||||
AND wr.signal_strength IS NOT NULL
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
"""),
|
||||
{
|
||||
"bucket_interval": bucket_interval,
|
||||
"lookback": lookback,
|
||||
"mac_address": mac_address,
|
||||
"device_id": str(device_id),
|
||||
"tenant_id": str(tenant_id),
|
||||
},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = [
|
||||
SignalHistoryPoint(
|
||||
timestamp=row.bucket,
|
||||
signal_avg=row.signal_avg,
|
||||
signal_min=row.signal_min,
|
||||
signal_max=row.signal_max,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return SignalHistoryResponse(items=items, mac_address=mac_address, range=range)
|
||||
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