diff --git a/backend/app/main.py b/backend/app/main.py index 4640b1b..c9c0ebf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/routers/signal_history.py b/backend/app/routers/signal_history.py new file mode 100644 index 0000000..b6b4133 --- /dev/null +++ b/backend/app/routers/signal_history.py @@ -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, + ) diff --git a/backend/app/routers/site_alerts.py b/backend/app/routers/site_alerts.py new file mode 100644 index 0000000..3c29782 --- /dev/null +++ b/backend/app/routers/site_alerts.py @@ -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} diff --git a/backend/app/services/signal_history_service.py b/backend/app/services/signal_history_service.py new file mode 100644 index 0000000..acd2135 --- /dev/null +++ b/backend/app/services/signal_history_service.py @@ -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) diff --git a/backend/app/services/site_alert_service.py b/backend/app/services/site_alert_service.py new file mode 100644 index 0000000..f704ab5 --- /dev/null +++ b/backend/app/services/site_alert_service.py @@ -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