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:
2026-03-20 15:01:33 +01:00
commit 330ba7a93d
35 changed files with 5038 additions and 0 deletions
+151
View File
@@ -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()