5393e85a74
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>
167 lines
5.4 KiB
Python
167 lines
5.4 KiB
Python
"""
|
|
CRUD for utøverprofiler og startliste.
|
|
Bruker aiosqlite for async tilgang fra FastAPI.
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
import aiosqlite
|
|
|
|
DB_PATH = "/data/timing.db"
|
|
|
|
|
|
async def init_db(db: aiosqlite.Connection) -> None:
|
|
await db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS athletes (
|
|
profile_id TEXT PRIMARY KEY,
|
|
bib_number TEXT UNIQUE,
|
|
name TEXT,
|
|
club TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
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,
|
|
timestamp_utc TEXT NOT NULL,
|
|
gps_lat REAL,
|
|
gps_lon REAL,
|
|
gps_alt REAL,
|
|
confidence REAL,
|
|
id_method TEXT,
|
|
source_image TEXT,
|
|
needs_review INTEGER NOT NULL DEFAULT 0,
|
|
review_note TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS passage_images (
|
|
image_id TEXT PRIMARY KEY,
|
|
passage_id TEXT NOT NULL REFERENCES passages(passage_id) ON DELETE CASCADE,
|
|
image_path TEXT NOT NULL,
|
|
timestamp_utc TEXT NOT NULL,
|
|
proximity_score REAL NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
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)
|
|
db.row_factory = aiosqlite.Row
|
|
await init_db(db)
|
|
return db
|
|
|
|
|
|
# --- Utøverprofiler ---
|
|
|
|
async def upsert_athlete(
|
|
db: aiosqlite.Connection,
|
|
bib_number: str,
|
|
name: str,
|
|
club: Optional[str] = None,
|
|
) -> str:
|
|
"""Opprett eller oppdater utøver basert på startnummer. Returnerer profile_id."""
|
|
async with db.execute(
|
|
"SELECT profile_id FROM athletes WHERE bib_number = ?", (bib_number,)
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
|
|
if row:
|
|
profile_id = row["profile_id"]
|
|
await db.execute(
|
|
"UPDATE athletes SET name = ?, club = ? WHERE profile_id = ?",
|
|
(name, club, profile_id),
|
|
)
|
|
else:
|
|
profile_id = str(uuid.uuid4())
|
|
await db.execute(
|
|
"INSERT INTO athletes (profile_id, bib_number, name, club) VALUES (?, ?, ?, ?)",
|
|
(profile_id, bib_number, name, club),
|
|
)
|
|
await db.commit()
|
|
return profile_id
|
|
|
|
|
|
async def import_startlist_csv(db: aiosqlite.Connection, csv_content: str) -> dict:
|
|
"""
|
|
Importer startliste fra CSV-streng.
|
|
Forventet kolonner: bib_number, name (og valgfritt: club)
|
|
Returnerer {'imported': N, 'errors': [...]}
|
|
"""
|
|
reader = csv.DictReader(io.StringIO(csv_content))
|
|
imported = 0
|
|
errors = []
|
|
|
|
# Normaliser kolonnenavn (fjern whitespace, lowercase)
|
|
fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])]
|
|
if "bib_number" not in fieldnames and "bib" not in fieldnames:
|
|
return {"imported": 0, "errors": ["CSV mangler 'bib_number'-kolonne"]}
|
|
if "name" not in fieldnames:
|
|
return {"imported": 0, "errors": ["CSV mangler 'name'-kolonne"]}
|
|
|
|
bib_col = "bib_number" if "bib_number" in fieldnames else "bib"
|
|
|
|
for i, row in enumerate(reader, start=2):
|
|
# Normaliser nøkler
|
|
row = {k.strip().lower(): v.strip() for k, v in row.items()}
|
|
bib = row.get(bib_col, "").strip()
|
|
name = row.get("name", "").strip()
|
|
club = row.get("club", "").strip() or None
|
|
|
|
if not bib or not name:
|
|
errors.append(f"Rad {i}: mangler bib eller navn")
|
|
continue
|
|
try:
|
|
await upsert_athlete(db, bib, name, club)
|
|
imported += 1
|
|
except Exception as e:
|
|
errors.append(f"Rad {i}: {e}")
|
|
|
|
return {"imported": imported, "errors": errors}
|
|
|
|
|
|
async def get_athlete_by_bib(db: aiosqlite.Connection, bib: str) -> Optional[dict]:
|
|
async with db.execute(
|
|
"SELECT * FROM athletes WHERE bib_number = ?", (bib,)
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
async def list_athletes(db: aiosqlite.Connection) -> list[dict]:
|
|
async with db.execute(
|
|
"SELECT * FROM athletes ORDER BY CAST(bib_number AS INTEGER), bib_number"
|
|
) as cur:
|
|
rows = await cur.fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
async def delete_athlete(db: aiosqlite.Connection, profile_id: str) -> bool:
|
|
cur = await db.execute(
|
|
"DELETE FROM athletes WHERE profile_id = ?", (profile_id,)
|
|
)
|
|
await db.commit()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
async def clear_startlist(db: aiosqlite.Connection) -> None:
|
|
await db.execute("DELETE FROM athletes")
|
|
await db.commit()
|