24645dfd11
Build & Deploy / build-and-deploy (push) Has been cancelled
Within a burst sequence from the same station, the image where the athlete is physically closest to the camera gives the most accurate passage timestamp. Proximity is measured by bib bounding box area (larger = closer). When a duplicate is detected: - New image closer: update timestamp + image path, delete old image - Existing image closer: discard new image Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
153 lines
4.8 KiB
Python
153 lines
4.8 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,
|
|
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,
|
|
proximity_score REAL NOT NULL DEFAULT 0,
|
|
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 INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_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);
|
|
""")
|
|
await db.commit()
|
|
|
|
|
|
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()
|