Initial commit: MVP tidtakingssystem
- Backend: FastAPI, EXIF-parser, EasyOCR, SQLite - Frontend: React admin (startliste, passeringer, gjennomgang, resultater) - Docker: docker-compose med depot/processed/data-volumer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
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 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()
|
||||
Reference in New Issue
Block a user