"""FastAPI routes — dashboard API + auth.""" from __future__ import annotations import secrets from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials from pydantic import BaseModel from admin.models import ( activate_flow_config, delete_flow_config, get_flow_config, get_stats, list_flow_configs, upsert_flow_config, ) from config import config router = APIRouter() security = HTTPBasic() def require_auth(credentials: Annotated[HTTPBasicCredentials, Depends(security)]) -> str: ok_user = secrets.compare_digest(credentials.username.encode(), config.admin_username.encode()) ok_pass = secrets.compare_digest(credentials.password.encode(), config.admin_password.encode()) if not (ok_user and ok_pass): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username Auth = Annotated[str, Depends(require_auth)] # --------------------------------------------------------------------------- # Stats # --------------------------------------------------------------------------- @router.get("/api/stats") async def stats(_: Auth) -> dict[str, object]: return await get_stats() # --------------------------------------------------------------------------- # Flow configs # --------------------------------------------------------------------------- class FlowConfigIn(BaseModel): name: str is_active: bool = False search_texts: list[str] = [] search_box_selector: str = "" search_box_selector_type: str = "css" search_submit_selector: str = "" search_submit_selector_type: str = "css" search_results_selector: str = "" scroll_container_selector: str = "" max_scrolls: int = 30 scroll_pause_ms: int = 1200 no_new_content_timeout_ms: int = 3000 item_selector_template: str = "" item_selector_type: str = "css" target_item_ids: list[str] = [] def _to_db_dict(body: FlowConfigIn) -> dict[str, Any]: return { "name": body.name, "is_active": int(body.is_active), "search_texts_json": body.search_texts, "search_box_selector": body.search_box_selector, "search_box_selector_type": body.search_box_selector_type, "search_submit_selector": body.search_submit_selector, "search_submit_selector_type": body.search_submit_selector_type, "search_results_selector": body.search_results_selector, "scroll_container_selector": body.scroll_container_selector, "max_scrolls": body.max_scrolls, "scroll_pause_ms": body.scroll_pause_ms, "no_new_content_timeout_ms": body.no_new_content_timeout_ms, "item_selector_template": body.item_selector_template, "item_selector_type": body.item_selector_type, "target_item_ids_json": body.target_item_ids, } @router.get("/api/flow-configs") async def list_configs(_: Auth) -> list[dict[str, object]]: return await list_flow_configs() # type: ignore[return-value] @router.get("/api/flow-configs/{cfg_id}") async def get_config(cfg_id: int, _: Auth) -> dict[str, object]: row = await get_flow_config(cfg_id) if not row: raise HTTPException(status_code=404, detail="Not found") return row # type: ignore[return-value] @router.post("/api/flow-configs", status_code=201) async def create_config(body: FlowConfigIn, _: Auth) -> dict[str, object]: new_id = await upsert_flow_config(_to_db_dict(body)) row = await get_flow_config(new_id) return row or {} # type: ignore[return-value] @router.put("/api/flow-configs/{cfg_id}") async def update_config(cfg_id: int, body: FlowConfigIn, _: Auth) -> dict[str, object]: existing = await get_flow_config(cfg_id) if not existing: raise HTTPException(status_code=404, detail="Not found") await upsert_flow_config(_to_db_dict(body), cfg_id=cfg_id) row = await get_flow_config(cfg_id) return row or {} # type: ignore[return-value] @router.post("/api/flow-configs/{cfg_id}/activate", status_code=200) async def activate_config(cfg_id: int, _: Auth) -> dict[str, object]: existing = await get_flow_config(cfg_id) if not existing: raise HTTPException(status_code=404, detail="Not found") await activate_flow_config(cfg_id) return {"activated": cfg_id} @router.delete("/api/flow-configs/{cfg_id}", status_code=204) async def delete_config(cfg_id: int, _: Auth) -> None: existing = await get_flow_config(cfg_id) if not existing: raise HTTPException(status_code=404, detail="Not found") await delete_flow_config(cfg_id)