Files
timing/backend/profile_db.py
T
steinhelge 018f84efd8
Build & Deploy / build-and-deploy (push) Successful in 45s
Auto-create athlete in startlist for unrecognized bib numbers
When a bib number is detected (via OCR or manual entry during review)
but not found in the start list, it is now automatically added with
the placeholder name "Ukjent #<nr>" instead of being left without a
profile_id (which would exclude it from results).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:51:03 +01:00

188 lines
6.2 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_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()
# Migrer eksisterende tabeller
await _migrate(db)
from race_db import init_race_tables
await init_race_tables(db)
async def _migrate(db: aiosqlite.Connection) -> None:
"""Legg til nye kolonner i eksisterende tabeller ved oppgradering."""
async with db.execute("PRAGMA table_info(passages)") as cur:
columns = {row[1] for row in await cur.fetchall()}
if "race_id" not in columns:
await db.execute("ALTER TABLE passages ADD COLUMN race_id TEXT REFERENCES races(race_id)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_passages_race ON passages(race_id)")
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_or_create_athlete(db: aiosqlite.Connection, bib_number: str) -> str:
"""Hent eller opprett utøver basert på startnummer. Returnerer profile_id."""
athlete = await get_athlete_by_bib(db, bib_number)
if athlete:
return athlete["profile_id"]
return await upsert_athlete(db, bib_number, f"Ukjent #{bib_number}")
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()