Initial commit: MVP tidtakingssystem
- Backend: FastAPI, EXIF-parser, EasyOCR, SQLite - Frontend: React admin (startliste, passeringer, gjennomgang, resultater) - Docker: docker-compose med depot/processed/data-volumer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
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_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.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)
|
||||
|
||||
|
||||
# =====================
|
||||
# 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"}
|
||||
Reference in New Issue
Block a user