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:
Jason Staack
2026-03-19 07:18:02 -05:00
parent b9a92f3869
commit 124a72582b
5 changed files with 720 additions and 0 deletions

View File

@@ -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"])

View 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,
)

View 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}

View 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)

View 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