Files
timing/backend/api.py
T
steinhelge 82e1124f9f
Build & Deploy / build-and-deploy (push) Successful in 37s
Add web upload: drag-and-drop images to depot via browser
- POST /api/upload saves files to /depot/ for ingest processing
- Batches of 10 files per request
- Drag-and-drop zone + file picker, per-file status feedback
- New 'Last opp'-tab in navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:33:27 +01:00

221 lines
6.1 KiB
Python

"""
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"}