""" FastAPI REST API for timing-systemet. """ import asyncio import logging from contextlib import asynccontextmanager from pathlib import Path import aiosqlite from fastapi import Depends, FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Optional from ingest import watch_depot, process_existing from passage_log import delete_passage, get_passage_images, get_passages, log_passage, resolve_passage from profile_db import ( clear_startlist, delete_athlete, get_db, import_startlist_csv, list_athletes, upsert_athlete, ) from results import get_results logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): # Behandle bilder som lå i depot fra før oppstart await process_existing() # Start fil-overvåkning i bakgrunnen asyncio.create_task(watch_depot()) yield app = FastAPI(title="Timing API", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) # --- DB-avhengighet --- async def get_connection(): db = await get_db() try: yield db finally: await db.close() # ===================== # Startliste-endepunkter # ===================== @app.get("/api/athletes") async def list_athletes_endpoint(db=Depends(get_connection)): return await list_athletes(db) @app.post("/api/athletes/import", summary="Last opp startliste som CSV") async def import_csv( file: UploadFile = File(...), db=Depends(get_connection), ): if not file.filename.endswith(".csv"): raise HTTPException(400, "Kun CSV-filer støttes") content = (await file.read()).decode("utf-8-sig") # utf-8-sig håndterer BOM result = await import_startlist_csv(db, content) return result @app.delete("/api/athletes/all") async def clear_all_athletes(db=Depends(get_connection)): await clear_startlist(db) return {"ok": True} @app.delete("/api/athletes/{profile_id}") async def remove_athlete(profile_id: str, db=Depends(get_connection)): ok = await delete_athlete(db, profile_id) if not ok: raise HTTPException(404, "Utøver ikke funnet") return {"ok": True} # ===================== # Passeringer # ===================== @app.get("/api/passages") async def list_passages( station: Optional[str] = None, needs_review: Optional[bool] = None, profile_id: Optional[str] = None, db=Depends(get_connection), ): return await get_passages(db, profile_id=profile_id, station=station, needs_review=needs_review) @app.get("/api/passages/review", summary="Hent passeringer som trenger manuell gjennomgang") async def review_queue(db=Depends(get_connection)): return await get_passages(db, needs_review=True) class ResolveRequest(BaseModel): profile_id: Optional[str] = None bib_number: Optional[str] = None review_note: Optional[str] = None @app.post("/api/passages/{passage_id}/resolve") async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)): ok = await resolve_passage( db, passage_id, profile_id=body.profile_id, bib_number=body.bib_number, review_note=body.review_note, ) if not ok: raise HTTPException(404, "Passering ikke funnet") return {"ok": True} @app.get("/api/passages/{passage_id}/images") async def passage_images(passage_id: str, db=Depends(get_connection)): """Hent alle bilder for en passering, kronologisk sortert.""" return await get_passage_images(db, passage_id) @app.delete("/api/passages/{passage_id}") async def remove_passage(passage_id: str, db=Depends(get_connection)): ok = await delete_passage(db, passage_id) if not ok: raise HTTPException(404, "Passering ikke funnet") return {"ok": True} # ===================== # Resultater # ===================== @app.get("/api/results") async def get_results_endpoint(db=Depends(get_connection)): return await get_results(db) # ===================== # Bildeopplasting # ===================== VALID_IMAGE_TYPES = {"image/jpeg", "image/png"} VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"} @app.post("/api/upload", summary="Last opp ett eller flere bilder til depot") async def upload_images(files: list[UploadFile] = File(...)): """ Lagrer opplastede bilder i /depot/ slik at ingest-prosessen plukker dem opp. Returnerer status per fil. """ results = [] for file in files: suffix = Path(file.filename).suffix.lower() if suffix not in VALID_IMAGE_SUFFIXES: results.append({"filename": file.filename, "ok": False, "error": "Ugyldig filtype"}) continue dest = Path("/depot") / file.filename # Unngå overskrivning ved navnekollisjon if dest.exists(): import uuid as _uuid dest = Path("/depot") / f"{_uuid.uuid4().hex}_{file.filename}" try: content = await file.read() dest.write_bytes(content) results.append({"filename": file.filename, "ok": True, "saved_as": dest.name}) except Exception as e: results.append({"filename": file.filename, "ok": False, "error": str(e)}) return {"uploaded": sum(1 for r in results if r["ok"]), "results": results} # ===================== # Bilder # ===================== @app.get("/api/images/{path:path}") async def serve_image(path: str): """Lever behandlede bilder til frontend.""" full_path = Path("/processed") / path if not full_path.exists() or not full_path.is_file(): raise HTTPException(404, "Bilde ikke funnet") # Sjekk at stien er innenfor /processed (path traversal) try: full_path.resolve().relative_to(Path("/processed").resolve()) except ValueError: raise HTTPException(403, "Forbudt") return FileResponse(full_path) # ===================== # Helse # ===================== @app.get("/api/health") async def health(): return {"status": "ok"}