from typing import Annotated, Any import asyncpg from dishka.integrations.fastapi import DishkaRoute, FromDishka from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from utils.read import watches from utils.read.models import AlertPage, WatchItem, WatchView from utils.watch_rules import WatchParamsError, clean_name, normalize_params router = APIRouter(prefix="/api", tags=["watches"], route_class=DishkaRoute) class WatchUpdate(BaseModel): kind: str | None = None params: dict[str, Any] = {} name: str | None = None enabled: bool = True class WatchCreate(WatchUpdate): account_id: int kind: str class SeenAll(BaseModel): account_id: int up_to: int | None = None def _params(kind: str, params: dict[str, Any]) -> dict[str, Any]: try: return normalize_params(kind, params) except WatchParamsError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc async def _item(pool: asyncpg.Pool, watch: WatchView) -> WatchItem: return (await watches.describe_watches(pool, watch.account_id, [watch]))[0] @router.get("/watches") async def list_watches( pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] ) -> list[WatchItem]: rows = await watches.list_watches(pool, account_id) return await watches.describe_watches(pool, account_id, rows) @router.post("/watches", status_code=201) async def create_watch(pool: FromDishka[asyncpg.Pool], body: WatchCreate) -> WatchItem: watch = await watches.create_watch( pool, body.account_id, body.kind, _params(body.kind, body.params), name=clean_name(body.name), enabled=body.enabled, ) return await _item(pool, watch) @router.get("/watches/{watch_id}") async def get_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> WatchItem: watch = await watches.get_watch(pool, watch_id) if watch is None: raise HTTPException(status_code=404, detail="watch not found") return await _item(pool, watch) @router.put("/watches/{watch_id}") async def update_watch( pool: FromDishka[asyncpg.Pool], watch_id: int, body: WatchUpdate ) -> WatchItem: current = await watches.get_watch(pool, watch_id) if current is None: raise HTTPException(status_code=404, detail="watch not found") kind = body.kind or current.kind watch = await watches.update_watch( pool, watch_id, kind, _params(kind, body.params), name=clean_name(body.name), enabled=body.enabled, ) if watch is None: raise HTTPException(status_code=404, detail="watch not found") return await _item(pool, watch) @router.delete("/watches/{watch_id}", status_code=204) async def delete_watch(pool: FromDishka[asyncpg.Pool], watch_id: int) -> None: if not await watches.delete_watch(pool, watch_id): raise HTTPException(status_code=404, detail="watch not found") @router.get("/alerts") async def list_alerts( pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()], seen: Annotated[bool | None, Query()] = None, before: Annotated[int | None, Query()] = None, limit: Annotated[int, Query()] = watches.ALERT_PAGE, ) -> AlertPage: return await watches.list_alerts( pool, account_id, seen=seen, before=before, limit=limit ) @router.get("/alerts/unseen") async def unseen_alerts( pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] ) -> dict[str, int]: return {"unseen": await watches.unseen_count(pool, account_id)} @router.post("/alerts/seen") async def mark_all_seen( pool: FromDishka[asyncpg.Pool], body: SeenAll ) -> dict[str, int]: marked = await watches.mark_all_seen(pool, body.account_id, body.up_to) return {"marked": marked} @router.post("/alerts/{alert_id}/seen", status_code=204) async def mark_alert_seen(pool: FromDishka[asyncpg.Pool], alert_id: int) -> None: if not await watches.mark_alert_seen(pool, alert_id): raise HTTPException(status_code=404, detail="alert not found")