Files
timing/backend/profile_db.py
T
steinhelge 9c10124611
Build & Deploy / build-and-deploy (push) Failing after 2m41s
Keep all burst images, use last timestamp as passage time
- passage_images table stores every image in a burst sequence
- Passage timestamp = last image (chronologically) in the burst
- Review UI: image slider to browse all burst images, slider ends
  at the official passage time (rightmost = last image)
- API: GET /api/passages/{id}/images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 15:27:39 +01:00

162 lines
5.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,
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()
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()