from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from ..cursors import decode, decode_datetime, encode from ..db import get_session from ..models import Incident as IncidentModel from ..schemas import ID_PATTERN, Incident, IncidentsPage from ..timeutil import to_utc router = APIRouter() _SORT_COLUMNS = { "opened_at": IncidentModel.opened_at, "resolved_at": IncidentModel.resolved_at, } SortField = Literal["status", "opened_at", "resolved_at"] # PH-review: the rank is materialized as a STORED generated column # `incidents.status_rank` (see models.py / 0001_baseline.py). This map mirrors # the SQL CASE for Python-side cursor encoding only. _STATUS_RANK: dict[tuple[str, str], int] = { ("open", "critical"): 5, ("open", "warning"): 4, ("open", "unknown"): 3, ("resolved", "critical"): 2, ("resolved", "warning"): 1, ("resolved", "unknown"): 0, } def _to_schema(i: IncidentModel) -> Incident: return Incident( id=str(i.id), incident_key=i.incident_key, state=i.state, severity=i.severity, agent_id=i.agent_id, check_id=i.check_id, opened_at=to_utc(i.opened_at), resolved_at=to_utc(i.resolved_at) if i.resolved_at else None, summary=i.summary, ) def _encode_incident_cursor(i: IncidentModel, sort: SortField) -> str: if sort == "status": return encode(i.status_rank, to_utc(i.opened_at).isoformat(), i.id) return encode(to_utc(getattr(i, sort)).isoformat(), i.id) @router.get("/incidents", response_model=IncidentsPage) async def list_incidents( state: Literal["open", "resolved"] | None = Query(default=None), severity: Literal["warning", "critical", "unknown"] | None = Query(default=None), agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN), sort: SortField = Query(default="status"), direction: Literal["asc", "desc"] = Query(default="desc"), cursor: str | None = Query(default=None, max_length=512), back: bool = Query(default=False), limit: int = Query(default=100, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> IncidentsPage: # PH-001 finding-4: sort=resolved_at must skip open incidents — their # resolved_at is NULL and would otherwise break to_utc() on the cursor. if sort == "resolved_at" and state is None: state = "resolved" if sort == "resolved_at" and state == "open": raise HTTPException(status_code=400, detail="sort=resolved_at requires state=resolved") # PH-review: `back=True` flips the effective sort to walk to the previous # page. We reverse the rows before returning so the response order matches # the canonical (user-requested) direction. effective_desc = (direction == "desc") ^ back if sort == "status": rank_col = IncidentModel.status_rank rank_order = rank_col.desc() if effective_desc else rank_col.asc() opened_order = ( IncidentModel.opened_at.desc() if effective_desc else IncidentModel.opened_at.asc() ) id_order = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc() stmt = select(IncidentModel).order_by(rank_order, opened_order, id_order) else: col = _SORT_COLUMNS[sort] order_col = col.desc() if effective_desc else col.asc() tiebreak = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc() stmt = select(IncidentModel).order_by(order_col, tiebreak) if sort == "resolved_at": stmt = stmt.where(IncidentModel.resolved_at.is_not(None)) if state is not None: stmt = stmt.where(IncidentModel.state == state) if severity is not None: stmt = stmt.where(IncidentModel.severity == severity) if agent_id is not None: stmt = stmt.where(IncidentModel.agent_id == agent_id) if check_id is not None: stmt = stmt.where(IncidentModel.check_id == check_id) if cursor is not None: if sort == "status": rank_str, ts_str, last_id = decode(cursor, 3) try: rank = int(rank_str) except ValueError as exc: raise HTTPException(status_code=400, detail="invalid cursor") from exc ts = decode_datetime(ts_str) rank_col = IncidentModel.status_rank rank_cmp = rank_col < rank if effective_desc else rank_col > rank opened_cmp = ( IncidentModel.opened_at < ts if effective_desc else IncidentModel.opened_at > ts ) id_cmp = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id stmt = stmt.where( or_( rank_cmp, and_(rank_col == rank, opened_cmp), and_(rank_col == rank, IncidentModel.opened_at == ts, id_cmp), ) ) else: col = _SORT_COLUMNS[sort] ts_str, last_id = decode(cursor, 2) ts = decode_datetime(ts_str) cmp = col < ts if effective_desc else col > ts tie = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id stmt = stmt.where(or_(cmp, and_(col == ts, tie))) rows = (await session.execute(stmt.limit(limit + 1))).scalars().all() has_more = len(rows) > limit rows = rows[:limit] if back: rows = list(reversed(rows)) next_cursor: str | None = None prev_cursor: str | None = None if rows: if back: next_cursor = _encode_incident_cursor(rows[-1], sort) if has_more: prev_cursor = _encode_incident_cursor(rows[0], sort) else: if has_more: next_cursor = _encode_incident_cursor(rows[-1], sort) if cursor is not None: prev_cursor = _encode_incident_cursor(rows[0], sort) return IncidentsPage( items=[_to_schema(r) for r in rows], next_cursor=next_cursor, prev_cursor=prev_cursor, )