Files
the-other-dude/backend/app/routers/events.py
Jason Staack 06a41ca9bf fix(lint): resolve all ruff lint errors
Add ruff config to exclude alembic E402, SQLAlchemy F821, and pre-existing
E501 line-length issues. Auto-fix 69 unused imports and 2 f-strings without
placeholders. Manually fix 8 unused variables. Apply ruff format to 127 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:17:50 -05:00

171 lines
6.2 KiB
Python

"""Unified events timeline API endpoint.
Provides a single GET endpoint that unions alert events, device status changes,
and config backup runs into a unified timeline for the dashboard.
RLS enforced via get_db() (app_user engine with tenant context).
"""
import logging
import uuid
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, set_tenant_context
from app.middleware.tenant_context import CurrentUser, get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["events"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _check_tenant_access(
current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession
) -> None:
"""Verify the current user is allowed to access the given tenant."""
if current_user.is_super_admin:
await set_tenant_context(db, str(tenant_id))
elif current_user.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant",
)
# ---------------------------------------------------------------------------
# Unified events endpoint
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/events",
summary="List unified events (alerts, status changes, config backups)",
)
async def list_events(
tenant_id: uuid.UUID,
limit: int = Query(50, ge=1, le=200, description="Max events to return"),
event_type: Optional[str] = Query(
None,
description="Filter by event type: alert, status_change, config_backup",
),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict[str, Any]]:
"""Return a unified list of recent events across alerts, device status, and config backups.
Events are ordered by timestamp descending, limited to `limit` (default 50).
RLS automatically filters to the tenant's data via the app_user session.
"""
await _check_tenant_access(current_user, tenant_id, db)
if event_type and event_type not in ("alert", "status_change", "config_backup"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="event_type must be one of: alert, status_change, config_backup",
)
events: list[dict[str, Any]] = []
# 1. Alert events
if not event_type or event_type == "alert":
alert_result = await db.execute(
text("""
SELECT ae.id, ae.status, ae.severity, ae.metric, ae.message,
ae.fired_at, ae.device_id, d.hostname
FROM alert_events ae
LEFT JOIN devices d ON d.id = ae.device_id
ORDER BY ae.fired_at DESC
LIMIT :limit
"""),
{"limit": limit},
)
for row in alert_result.fetchall():
alert_status = row[1] or "firing"
metric = row[3] or "unknown"
events.append(
{
"id": str(row[0]),
"event_type": "alert",
"severity": row[2],
"title": f"{alert_status}: {metric}",
"description": row[4] or f"Alert {alert_status} for {metric}",
"device_hostname": row[7],
"device_id": str(row[6]) if row[6] else None,
"timestamp": row[5].isoformat() if row[5] else None,
}
)
# 2. Device status changes (inferred from current status + last_seen)
if not event_type or event_type == "status_change":
status_result = await db.execute(
text("""
SELECT d.id, d.hostname, d.status, d.last_seen
FROM devices d
WHERE d.last_seen IS NOT NULL
ORDER BY d.last_seen DESC
LIMIT :limit
"""),
{"limit": limit},
)
for row in status_result.fetchall():
device_status = row[2] or "unknown"
hostname = row[1] or "Unknown device"
severity = "info" if device_status == "online" else "warning"
events.append(
{
"id": f"status-{row[0]}",
"event_type": "status_change",
"severity": severity,
"title": f"Device {device_status}",
"description": f"{hostname} is now {device_status}",
"device_hostname": hostname,
"device_id": str(row[0]),
"timestamp": row[3].isoformat() if row[3] else None,
}
)
# 3. Config backup runs
if not event_type or event_type == "config_backup":
backup_result = await db.execute(
text("""
SELECT cbr.id, cbr.trigger_type, cbr.created_at,
cbr.device_id, d.hostname
FROM config_backup_runs cbr
LEFT JOIN devices d ON d.id = cbr.device_id
ORDER BY cbr.created_at DESC
LIMIT :limit
"""),
{"limit": limit},
)
for row in backup_result.fetchall():
trigger_type = row[1] or "manual"
hostname = row[4] or "Unknown device"
events.append(
{
"id": str(row[0]),
"event_type": "config_backup",
"severity": "info",
"title": "Config backup",
"description": f"{trigger_type} backup completed for {hostname}",
"device_hostname": hostname,
"device_id": str(row[3]) if row[3] else None,
"timestamp": row[2].isoformat() if row[2] else None,
}
)
# Sort all events by timestamp descending, then apply final limit
events.sort(
key=lambda e: e["timestamp"] or "",
reverse=True,
)
return events[:limit]