Add race and station management
Build & Deploy / build-and-deploy (push) Successful in 2m18s

- races table: name, date, description, is_active
- stations table: ordered checkpoints with GPS per race
- New /api/races and /api/races/{id}/stations endpoints
- Upload now requires race + station selection; uses station GPS
  so images without GPS EXIF are accepted
- passages filtered by active race throughout
- RacePage: create races, manage stations (add/edit/delete checkpoints)
- Navbar shows active race name
- Start and finish stations created automatically per race

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:44:45 +01:00
parent 3dcf979e6f
commit 5393e85a74
15 changed files with 841 additions and 139 deletions
+148 -38
View File
@@ -4,14 +4,15 @@ 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 fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
@@ -23,7 +24,10 @@ from profile_db import (
get_db,
import_startlist_csv,
list_athletes,
upsert_athlete,
)
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
@@ -32,9 +36,7 @@ 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
@@ -49,8 +51,6 @@ app.add_middleware(
)
# --- DB-avhengighet ---
async def get_connection():
db = await get_db()
try:
@@ -60,7 +60,101 @@ async def get_connection():
# =====================
# Startliste-endepunkter
# 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")
@@ -69,15 +163,11 @@ async def list_athletes_endpoint(db=Depends(get_connection)):
@app.post("/api/athletes/import", summary="Last opp startliste som CSV")
async def import_csv(
file: UploadFile = File(...),
db=Depends(get_connection),
):
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
content = (await file.read()).decode("utf-8-sig")
return await import_startlist_csv(db, content)
@app.delete("/api/athletes/all")
@@ -100,17 +190,19 @@ async def remove_athlete(profile_id: str, db=Depends(get_connection)):
@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, profile_id=profile_id, station=station, needs_review=needs_review)
return await get_passages(db, race_id=race_id, 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)
@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):
@@ -121,13 +213,10 @@ class ResolveRequest(BaseModel):
@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,
)
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}
@@ -135,7 +224,6 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti
@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)
@@ -152,24 +240,35 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)):
# =====================
@app.get("/api/results")
async def get_results_endpoint(db=Depends(get_connection)):
return await get_results(db)
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_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(...)):
@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),
):
"""
Lagrer opplastede bilder i /depot/ slik at ingest-prosessen plukker dem opp.
Returnerer status per fil.
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()
@@ -178,16 +277,29 @@ async def upload_images(files: list[UploadFile] = File(...)):
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}"
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}
@@ -199,11 +311,9 @@ async def upload_images(files: list[UploadFile] = File(...)):
@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: