- 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:
+148
-38
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user