""" FastAPI REST API for timing-systemet. """ import asyncio import logging import uuid from contextlib import asynccontextmanager from datetime import datetime, timezone 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 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, get_or_create_athlete, import_startlist_csv, list_athletes, ) from race_db import ( create_race, delete_race, get_active_race, get_race, get_station, list_races, list_stations, set_active_race, update_race, upsert_station, delete_station, ) from results import get_results logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): await process_existing() 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=["*"], ) async def get_connection(): db = await get_db() try: yield db finally: await db.close() # ===================== # Løp (races) # ===================== class RaceRequest(BaseModel): name: str date: Optional[str] = None description: Optional[str] = None @app.get("/api/races") async def list_races_endpoint(db=Depends(get_connection)): return await list_races(db) @app.get("/api/races/active") async def active_race(db=Depends(get_connection)): race = await get_active_race(db) return race or {} @app.post("/api/races") async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)): return await create_race(db, body.name, body.date, body.description) @app.put("/api/races/{race_id}") async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)): ok = await update_race(db, race_id, body.name, body.date, body.description) if not ok: raise HTTPException(404, "Løp ikke funnet") return await get_race(db, race_id) @app.post("/api/races/{race_id}/activate") async def activate_race(race_id: str, db=Depends(get_connection)): ok = await set_active_race(db, race_id) if not ok: raise HTTPException(404, "Løp ikke funnet") return {"ok": True} @app.delete("/api/races/{race_id}") async def delete_race_endpoint(race_id: str, db=Depends(get_connection)): ok = await delete_race(db, race_id) if not ok: raise HTTPException(404, "Løp ikke funnet") return {"ok": True} # ===================== # Stasjoner # ===================== class StationRequest(BaseModel): name: str display_name: str station_order: int gps_lat: Optional[float] = None gps_lon: Optional[float] = None gps_alt: Optional[float] = None @app.get("/api/races/{race_id}/stations") async def list_stations_endpoint(race_id: str, db=Depends(get_connection)): return await list_stations(db, race_id) @app.post("/api/races/{race_id}/stations") async def create_station_endpoint(race_id: str, body: StationRequest, db=Depends(get_connection)): return await upsert_station( db, race_id, body.name, body.display_name, body.station_order, body.gps_lat, body.gps_lon, body.gps_alt, ) @app.put("/api/races/{race_id}/stations/{station_id}") async def update_station_endpoint( race_id: str, station_id: str, body: StationRequest, db=Depends(get_connection) ): return await upsert_station( db, race_id, body.name, body.display_name, body.station_order, body.gps_lat, body.gps_lon, body.gps_alt, station_id=station_id, ) @app.delete("/api/races/{race_id}/stations/{station_id}") async def delete_station_endpoint(race_id: str, station_id: str, db=Depends(get_connection)): ok = await delete_station(db, station_id) if not ok: raise HTTPException(400, "Kan ikke slette start eller mål") return {"ok": True} # ===================== # Startliste # ===================== @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") return await import_startlist_csv(db, content) @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( race_id: Optional[str] = None, station: Optional[str] = None, needs_review: Optional[bool] = None, profile_id: Optional[str] = None, db=Depends(get_connection), ): return await get_passages(db, race_id=race_id, profile_id=profile_id, station=station, needs_review=needs_review) @app.get("/api/passages/review") async def review_queue(race_id: Optional[str] = None, db=Depends(get_connection)): return await get_passages(db, race_id=race_id, 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)): profile_id = body.profile_id if body.bib_number and not profile_id: profile_id = await get_or_create_athlete(db, body.bib_number) ok = await resolve_passage(db, passage_id, profile_id=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)): 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(race_id: Optional[str] = None, db=Depends(get_connection)): return await get_results(db, race_id=race_id) # ===================== # Bildeopplasting # ===================== VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"} @app.post("/api/upload") async def upload_images( files: list[UploadFile] = File(...), race_id: Optional[str] = None, station_id: Optional[str] = None, db=Depends(get_connection), ): """ Last opp bilder til depot. Med race_id + station_id behandles bildene direkte (ingen EXIF GPS-krav) og passeringen logges umiddelbart. """ # Hent stasjon for å få GPS og stasjonsnavn station = None if station_id: station = await get_station(db, station_id) if not station: raise HTTPException(404, "Stasjon ikke funnet") 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 if dest.exists(): dest = Path("/depot") / f"{uuid.uuid4().hex}_{file.filename}" try: content = await file.read() dest.write_bytes(content) if station: # Direktebehandling — flytt til processed og logg passering from ingest import process_image_with_override await process_image_with_override( dest, race_id=race_id, station_name=station["name"], gps_lat=station["gps_lat"], gps_lon=station["gps_lon"], gps_alt=station["gps_alt"], db=db, ) results.append({"filename": file.filename, "ok": True, "saved_as": dest.name}) except Exception as e: logger.exception("Feil ved opplasting av %s", file.filename) 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): full_path = Path("/processed") / path if not full_path.exists() or not full_path.is_file(): raise HTTPException(404, "Bilde ikke funnet") 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"}