- 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:
+2
-1
@@ -6,7 +6,8 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libgomp1 \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
+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:
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
|
||||
@@ -125,6 +126,69 @@ async def process_image(path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def process_image_with_override(
|
||||
path: Path,
|
||||
*,
|
||||
race_id: Optional[str],
|
||||
station_name: str,
|
||||
gps_lat: Optional[float],
|
||||
gps_lon: Optional[float],
|
||||
gps_alt: Optional[float],
|
||||
db,
|
||||
) -> None:
|
||||
"""
|
||||
Behandle bilde med manuelt oppgitt stasjon og GPS (fra web-opplasting).
|
||||
EXIF-tid brukes hvis tilgjengelig, ellers nåværende tidspunkt.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from profile_db import get_athlete_by_bib
|
||||
|
||||
logger.info("Web-opplasting: %s → stasjon=%s", path.name, station_name)
|
||||
|
||||
# Forsøk EXIF for tidsstempel, fallback til nå
|
||||
try:
|
||||
meta = parse_image(path)
|
||||
timestamp = meta.timestamp_utc
|
||||
except ExifError:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
ocr = read_bib(path)
|
||||
dest = _destination_path(path, timestamp)
|
||||
shutil.move(str(path), str(dest))
|
||||
|
||||
confidence = ocr.confidence
|
||||
needs_review = ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE
|
||||
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
|
||||
review_note = None if not needs_review else (
|
||||
"number_unreadable" if ocr.digits is None else "low_confidence"
|
||||
)
|
||||
|
||||
profile_id = None
|
||||
if ocr.digits and not needs_review:
|
||||
athlete = await get_athlete_by_bib(db, ocr.digits)
|
||||
if athlete:
|
||||
profile_id = athlete["profile_id"]
|
||||
|
||||
await log_passage(
|
||||
db,
|
||||
race_id=race_id,
|
||||
profile_id=profile_id,
|
||||
bib_number=ocr.digits,
|
||||
station=station_name,
|
||||
timestamp_utc=timestamp,
|
||||
gps_lat=gps_lat or 0.0,
|
||||
gps_lon=gps_lon or 0.0,
|
||||
gps_alt=gps_alt,
|
||||
confidence=confidence,
|
||||
proximity_score=ocr.proximity_score,
|
||||
id_method=id_method,
|
||||
source_image=str(dest),
|
||||
needs_review=needs_review,
|
||||
review_note=review_note,
|
||||
)
|
||||
logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name)
|
||||
|
||||
|
||||
async def process_existing() -> None:
|
||||
"""Behandle bilder som allerede ligger i depot/ ved oppstart."""
|
||||
for path in sorted(DEPOT_DIR.glob("*")):
|
||||
|
||||
@@ -50,6 +50,7 @@ async def _find_active_passage(
|
||||
async def log_passage(
|
||||
db: aiosqlite.Connection,
|
||||
*,
|
||||
race_id: Optional[str] = None,
|
||||
profile_id: Optional[str],
|
||||
bib_number: Optional[str],
|
||||
station: str,
|
||||
@@ -112,14 +113,14 @@ async def log_passage(
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO passages (
|
||||
passage_id, profile_id, bib_number, station,
|
||||
passage_id, race_id, profile_id, bib_number, station,
|
||||
timestamp_utc, gps_lat, gps_lon, gps_alt,
|
||||
confidence, id_method, source_image,
|
||||
needs_review, review_note
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
passage_id, profile_id, bib_number, station,
|
||||
passage_id, race_id, profile_id, bib_number, station,
|
||||
timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt,
|
||||
confidence, id_method, source_image,
|
||||
int(needs_review), review_note,
|
||||
@@ -153,12 +154,16 @@ async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[
|
||||
|
||||
async def get_passages(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: Optional[str] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
station: Optional[str] = None,
|
||||
needs_review: Optional[bool] = None,
|
||||
) -> list[dict]:
|
||||
clauses = []
|
||||
params = []
|
||||
if race_id is not None:
|
||||
clauses.append("p.race_id = ?")
|
||||
params.append(race_id)
|
||||
if profile_id is not None:
|
||||
clauses.append("p.profile_id = ?")
|
||||
params.append(profile_id)
|
||||
|
||||
@@ -25,6 +25,7 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passages (
|
||||
passage_id TEXT PRIMARY KEY,
|
||||
race_id TEXT REFERENCES races(race_id),
|
||||
profile_id TEXT REFERENCES athletes(profile_id),
|
||||
bib_number TEXT,
|
||||
station TEXT NOT NULL,
|
||||
@@ -50,12 +51,16 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_race ON passages(race_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station);
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review);
|
||||
CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
from race_db import init_race_tables
|
||||
await init_race_tables(db)
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
db = await aiosqlite.connect(DB_PATH)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
CRUD for løp (races) og stasjoner (stations).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def init_race_tables(db: aiosqlite.Connection) -> None:
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS races (
|
||||
race_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
date TEXT,
|
||||
description TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stations (
|
||||
station_id TEXT PRIMARY KEY,
|
||||
race_id TEXT NOT NULL REFERENCES races(race_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
station_order INTEGER NOT NULL DEFAULT 0,
|
||||
gps_lat REAL,
|
||||
gps_lon REAL,
|
||||
gps_alt REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(race_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Løp ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_race(
|
||||
db: aiosqlite.Connection,
|
||||
name: str,
|
||||
date: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> dict:
|
||||
race_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"INSERT INTO races (race_id, name, date, description) VALUES (?, ?, ?, ?)",
|
||||
(race_id, name, date, description),
|
||||
)
|
||||
# Sett som aktivt hvis det er første løpet
|
||||
async with db.execute("SELECT COUNT(*) FROM races") as cur:
|
||||
count = (await cur.fetchone())[0]
|
||||
if count == 1:
|
||||
await db.execute("UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,))
|
||||
|
||||
# Opprett standard start- og målstasjon
|
||||
for order, (sname, display) in enumerate([("start", "Start"), ("finish", "Mål")]):
|
||||
await db.execute(
|
||||
"""INSERT INTO stations (station_id, race_id, name, display_name, station_order)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(str(uuid.uuid4()), race_id, sname, display, order * 1000),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await get_race(db, race_id)
|
||||
|
||||
|
||||
async def get_race(db: aiosqlite.Connection, race_id: str) -> Optional[dict]:
|
||||
async with db.execute("SELECT * FROM races WHERE race_id = ?", (race_id,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def list_races(db: aiosqlite.Connection) -> list[dict]:
|
||||
async with db.execute("SELECT * FROM races ORDER BY date DESC, created_at DESC") as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def update_race(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: str,
|
||||
name: str,
|
||||
date: Optional[str],
|
||||
description: Optional[str],
|
||||
) -> bool:
|
||||
cur = await db.execute(
|
||||
"UPDATE races SET name = ?, date = ?, description = ? WHERE race_id = ?",
|
||||
(name, date, description, race_id),
|
||||
)
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def delete_race(db: aiosqlite.Connection, race_id: str) -> bool:
|
||||
cur = await db.execute("DELETE FROM races WHERE race_id = ?", (race_id,))
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def set_active_race(db: aiosqlite.Connection, race_id: str) -> bool:
|
||||
await db.execute("UPDATE races SET is_active = 0")
|
||||
cur = await db.execute(
|
||||
"UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def get_active_race(db: aiosqlite.Connection) -> Optional[dict]:
|
||||
async with db.execute("SELECT * FROM races WHERE is_active = 1 LIMIT 1") as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Stasjoner ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_stations(db: aiosqlite.Connection, race_id: str) -> list[dict]:
|
||||
async with db.execute(
|
||||
"SELECT * FROM stations WHERE race_id = ? ORDER BY station_order",
|
||||
(race_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_station(db: aiosqlite.Connection, station_id: str) -> Optional[dict]:
|
||||
async with db.execute(
|
||||
"SELECT * FROM stations WHERE station_id = ?", (station_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def create_station(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
station_order: int,
|
||||
gps_lat: Optional[float] = None,
|
||||
gps_lon: Optional[float] = None,
|
||||
gps_alt: Optional[float] = None,
|
||||
) -> dict:
|
||||
station_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"""INSERT INTO stations
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt),
|
||||
)
|
||||
await db.commit()
|
||||
return dict((await db.execute(
|
||||
"SELECT * FROM stations WHERE station_id = ?", (station_id,)
|
||||
)).get_cursor() or {})
|
||||
|
||||
|
||||
async def upsert_station(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
station_order: int,
|
||||
gps_lat: Optional[float] = None,
|
||||
gps_lon: Optional[float] = None,
|
||||
gps_alt: Optional[float] = None,
|
||||
station_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
if station_id:
|
||||
await db.execute(
|
||||
"""UPDATE stations SET name=?, display_name=?, station_order=?,
|
||||
gps_lat=?, gps_lon=?, gps_alt=? WHERE station_id=?""",
|
||||
(name, display_name, station_order, gps_lat, gps_lon, gps_alt, station_id),
|
||||
)
|
||||
else:
|
||||
station_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"""INSERT INTO stations
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt),
|
||||
)
|
||||
await db.commit()
|
||||
async with db.execute("SELECT * FROM stations WHERE station_id = ?", (station_id,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def delete_station(db: aiosqlite.Connection, station_id: str) -> bool:
|
||||
# Ikke tillat sletting av start/finish
|
||||
async with db.execute(
|
||||
"SELECT name FROM stations WHERE station_id = ?", (station_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if row and row["name"] in ("start", "finish"):
|
||||
return False
|
||||
cur = await db.execute("DELETE FROM stations WHERE station_id = ?", (station_id,))
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -2,7 +2,7 @@ fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pillow==11.1.0
|
||||
piexif==1.1.3
|
||||
easyocr==1.7.2
|
||||
pytesseract==0.3.13
|
||||
opencv-python-headless==4.11.0.86
|
||||
python-multipart==0.0.20
|
||||
watchdog==6.0.0
|
||||
|
||||
+7
-6
@@ -7,21 +7,22 @@ from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def get_results(db: aiosqlite.Connection) -> list[dict]:
|
||||
async def get_results(db: aiosqlite.Connection, race_id: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
Hent totalresultat for alle utøvere som har passert start og mål.
|
||||
Returnerer sortert liste med split-tider.
|
||||
"""
|
||||
# Hent alle bekreftede passeringer gruppert per utøver
|
||||
async with db.execute("""
|
||||
race_filter = "AND p.race_id = ?" if race_id else ""
|
||||
params = [race_id] if race_id else []
|
||||
|
||||
async with db.execute(f"""
|
||||
SELECT p.profile_id, p.bib_number, a.name, a.club,
|
||||
p.station, p.timestamp_utc
|
||||
FROM passages p
|
||||
LEFT JOIN athletes a ON a.profile_id = p.profile_id
|
||||
WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL
|
||||
WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL {race_filter}
|
||||
ORDER BY p.profile_id, p.timestamp_utc
|
||||
""") as cur:
|
||||
""", params) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
# Grupper per utøver
|
||||
|
||||
Reference in New Issue
Block a user