diff --git a/backend/Dockerfile b/backend/Dockerfile
index 8a8b9c9..9dfdb59 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -6,7 +6,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgl1 \
- libgomp1 \
+ tesseract-ocr \
+ tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
diff --git a/backend/api.py b/backend/api.py
index fad4a40..45c039b 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -4,14 +4,15 @@ FastAPI REST API for timing-systemet.
import asyncio
import logging
+import uuid
from contextlib import asynccontextmanager
+from datetime import datetime, timezone
from pathlib import Path
import aiosqlite
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
@@ -23,7 +24,10 @@ from profile_db import (
get_db,
import_startlist_csv,
list_athletes,
- upsert_athlete,
+)
+from race_db import (
+ create_race, delete_race, get_active_race, get_race, get_station,
+ list_races, list_stations, set_active_race, update_race, upsert_station, delete_station,
)
from results import get_results
@@ -32,9 +36,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
- # Behandle bilder som lå i depot fra før oppstart
await process_existing()
- # Start fil-overvåkning i bakgrunnen
asyncio.create_task(watch_depot())
yield
@@ -49,8 +51,6 @@ app.add_middleware(
)
-# --- DB-avhengighet ---
-
async def get_connection():
db = await get_db()
try:
@@ -60,7 +60,101 @@ async def get_connection():
# =====================
-# Startliste-endepunkter
+# Løp (races)
+# =====================
+
+class RaceRequest(BaseModel):
+ name: str
+ date: Optional[str] = None
+ description: Optional[str] = None
+
+
+@app.get("/api/races")
+async def list_races_endpoint(db=Depends(get_connection)):
+ return await list_races(db)
+
+
+@app.get("/api/races/active")
+async def active_race(db=Depends(get_connection)):
+ race = await get_active_race(db)
+ return race or {}
+
+
+@app.post("/api/races")
+async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)):
+ return await create_race(db, body.name, body.date, body.description)
+
+
+@app.put("/api/races/{race_id}")
+async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)):
+ ok = await update_race(db, race_id, body.name, body.date, body.description)
+ if not ok:
+ raise HTTPException(404, "Løp ikke funnet")
+ return await get_race(db, race_id)
+
+
+@app.post("/api/races/{race_id}/activate")
+async def activate_race(race_id: str, db=Depends(get_connection)):
+ ok = await set_active_race(db, race_id)
+ if not ok:
+ raise HTTPException(404, "Løp ikke funnet")
+ return {"ok": True}
+
+
+@app.delete("/api/races/{race_id}")
+async def delete_race_endpoint(race_id: str, db=Depends(get_connection)):
+ ok = await delete_race(db, race_id)
+ if not ok:
+ raise HTTPException(404, "Løp ikke funnet")
+ return {"ok": True}
+
+
+# =====================
+# Stasjoner
+# =====================
+
+class StationRequest(BaseModel):
+ name: str
+ display_name: str
+ station_order: int
+ gps_lat: Optional[float] = None
+ gps_lon: Optional[float] = None
+ gps_alt: Optional[float] = None
+
+
+@app.get("/api/races/{race_id}/stations")
+async def list_stations_endpoint(race_id: str, db=Depends(get_connection)):
+ return await list_stations(db, race_id)
+
+
+@app.post("/api/races/{race_id}/stations")
+async def create_station_endpoint(race_id: str, body: StationRequest, db=Depends(get_connection)):
+ return await upsert_station(
+ db, race_id, body.name, body.display_name, body.station_order,
+ body.gps_lat, body.gps_lon, body.gps_alt,
+ )
+
+
+@app.put("/api/races/{race_id}/stations/{station_id}")
+async def update_station_endpoint(
+ race_id: str, station_id: str, body: StationRequest, db=Depends(get_connection)
+):
+ return await upsert_station(
+ db, race_id, body.name, body.display_name, body.station_order,
+ body.gps_lat, body.gps_lon, body.gps_alt, station_id=station_id,
+ )
+
+
+@app.delete("/api/races/{race_id}/stations/{station_id}")
+async def delete_station_endpoint(race_id: str, station_id: str, db=Depends(get_connection)):
+ ok = await delete_station(db, station_id)
+ if not ok:
+ raise HTTPException(400, "Kan ikke slette start eller mål")
+ return {"ok": True}
+
+
+# =====================
+# Startliste
# =====================
@app.get("/api/athletes")
@@ -69,15 +163,11 @@ async def list_athletes_endpoint(db=Depends(get_connection)):
@app.post("/api/athletes/import", summary="Last opp startliste som CSV")
-async def import_csv(
- file: UploadFile = File(...),
- db=Depends(get_connection),
-):
+async def import_csv(file: UploadFile = File(...), db=Depends(get_connection)):
if not file.filename.endswith(".csv"):
raise HTTPException(400, "Kun CSV-filer støttes")
- content = (await file.read()).decode("utf-8-sig") # utf-8-sig håndterer BOM
- result = await import_startlist_csv(db, content)
- return result
+ content = (await file.read()).decode("utf-8-sig")
+ return await import_startlist_csv(db, content)
@app.delete("/api/athletes/all")
@@ -100,17 +190,19 @@ async def remove_athlete(profile_id: str, db=Depends(get_connection)):
@app.get("/api/passages")
async def list_passages(
+ race_id: Optional[str] = None,
station: Optional[str] = None,
needs_review: Optional[bool] = None,
profile_id: Optional[str] = None,
db=Depends(get_connection),
):
- return await get_passages(db, profile_id=profile_id, station=station, needs_review=needs_review)
+ return await get_passages(db, race_id=race_id, profile_id=profile_id,
+ station=station, needs_review=needs_review)
-@app.get("/api/passages/review", summary="Hent passeringer som trenger manuell gjennomgang")
-async def review_queue(db=Depends(get_connection)):
- return await get_passages(db, needs_review=True)
+@app.get("/api/passages/review")
+async def review_queue(race_id: Optional[str] = None, db=Depends(get_connection)):
+ return await get_passages(db, race_id=race_id, needs_review=True)
class ResolveRequest(BaseModel):
@@ -121,13 +213,10 @@ class ResolveRequest(BaseModel):
@app.post("/api/passages/{passage_id}/resolve")
async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)):
- ok = await resolve_passage(
- db,
- passage_id,
- profile_id=body.profile_id,
- bib_number=body.bib_number,
- review_note=body.review_note,
- )
+ ok = await resolve_passage(db, passage_id,
+ profile_id=body.profile_id,
+ bib_number=body.bib_number,
+ review_note=body.review_note)
if not ok:
raise HTTPException(404, "Passering ikke funnet")
return {"ok": True}
@@ -135,7 +224,6 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti
@app.get("/api/passages/{passage_id}/images")
async def passage_images(passage_id: str, db=Depends(get_connection)):
- """Hent alle bilder for en passering, kronologisk sortert."""
return await get_passage_images(db, passage_id)
@@ -152,24 +240,35 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)):
# =====================
@app.get("/api/results")
-async def get_results_endpoint(db=Depends(get_connection)):
- return await get_results(db)
+async def get_results_endpoint(race_id: Optional[str] = None, db=Depends(get_connection)):
+ return await get_results(db, race_id=race_id)
# =====================
# Bildeopplasting
# =====================
-VALID_IMAGE_TYPES = {"image/jpeg", "image/png"}
VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
-@app.post("/api/upload", summary="Last opp ett eller flere bilder til depot")
-async def upload_images(files: list[UploadFile] = File(...)):
+@app.post("/api/upload")
+async def upload_images(
+ files: list[UploadFile] = File(...),
+ race_id: Optional[str] = None,
+ station_id: Optional[str] = None,
+ db=Depends(get_connection),
+):
"""
- Lagrer opplastede bilder i /depot/ slik at ingest-prosessen plukker dem opp.
- Returnerer status per fil.
+ Last opp bilder til depot. Med race_id + station_id behandles bildene
+ direkte (ingen EXIF GPS-krav) og passeringen logges umiddelbart.
"""
+ # Hent stasjon for å få GPS og stasjonsnavn
+ station = None
+ if station_id:
+ station = await get_station(db, station_id)
+ if not station:
+ raise HTTPException(404, "Stasjon ikke funnet")
+
results = []
for file in files:
suffix = Path(file.filename).suffix.lower()
@@ -178,16 +277,29 @@ async def upload_images(files: list[UploadFile] = File(...)):
continue
dest = Path("/depot") / file.filename
- # Unngå overskrivning ved navnekollisjon
if dest.exists():
- import uuid as _uuid
- dest = Path("/depot") / f"{_uuid.uuid4().hex}_{file.filename}"
+ dest = Path("/depot") / f"{uuid.uuid4().hex}_{file.filename}"
try:
content = await file.read()
dest.write_bytes(content)
+
+ if station:
+ # Direktebehandling — flytt til processed og logg passering
+ from ingest import process_image_with_override
+ await process_image_with_override(
+ dest,
+ race_id=race_id,
+ station_name=station["name"],
+ gps_lat=station["gps_lat"],
+ gps_lon=station["gps_lon"],
+ gps_alt=station["gps_alt"],
+ db=db,
+ )
+
results.append({"filename": file.filename, "ok": True, "saved_as": dest.name})
except Exception as e:
+ logger.exception("Feil ved opplasting av %s", file.filename)
results.append({"filename": file.filename, "ok": False, "error": str(e)})
return {"uploaded": sum(1 for r in results if r["ok"]), "results": results}
@@ -199,11 +311,9 @@ async def upload_images(files: list[UploadFile] = File(...)):
@app.get("/api/images/{path:path}")
async def serve_image(path: str):
- """Lever behandlede bilder til frontend."""
full_path = Path("/processed") / path
if not full_path.exists() or not full_path.is_file():
raise HTTPException(404, "Bilde ikke funnet")
- # Sjekk at stien er innenfor /processed (path traversal)
try:
full_path.resolve().relative_to(Path("/processed").resolve())
except ValueError:
diff --git a/backend/ingest.py b/backend/ingest.py
index 2f44f49..c2021ba 100644
--- a/backend/ingest.py
+++ b/backend/ingest.py
@@ -14,6 +14,7 @@ import logging
import shutil
import uuid
from pathlib import Path
+from typing import Optional
import aiosqlite
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
@@ -125,6 +126,69 @@ async def process_image(path: Path) -> None:
)
+async def process_image_with_override(
+ path: Path,
+ *,
+ race_id: Optional[str],
+ station_name: str,
+ gps_lat: Optional[float],
+ gps_lon: Optional[float],
+ gps_alt: Optional[float],
+ db,
+) -> None:
+ """
+ Behandle bilde med manuelt oppgitt stasjon og GPS (fra web-opplasting).
+ EXIF-tid brukes hvis tilgjengelig, ellers nåværende tidspunkt.
+ """
+ from datetime import datetime, timezone
+ from profile_db import get_athlete_by_bib
+
+ logger.info("Web-opplasting: %s → stasjon=%s", path.name, station_name)
+
+ # Forsøk EXIF for tidsstempel, fallback til nå
+ try:
+ meta = parse_image(path)
+ timestamp = meta.timestamp_utc
+ except ExifError:
+ timestamp = datetime.now(timezone.utc)
+
+ ocr = read_bib(path)
+ dest = _destination_path(path, timestamp)
+ shutil.move(str(path), str(dest))
+
+ confidence = ocr.confidence
+ needs_review = ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE
+ id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
+ review_note = None if not needs_review else (
+ "number_unreadable" if ocr.digits is None else "low_confidence"
+ )
+
+ profile_id = None
+ if ocr.digits and not needs_review:
+ athlete = await get_athlete_by_bib(db, ocr.digits)
+ if athlete:
+ profile_id = athlete["profile_id"]
+
+ await log_passage(
+ db,
+ race_id=race_id,
+ profile_id=profile_id,
+ bib_number=ocr.digits,
+ station=station_name,
+ timestamp_utc=timestamp,
+ gps_lat=gps_lat or 0.0,
+ gps_lon=gps_lon or 0.0,
+ gps_alt=gps_alt,
+ confidence=confidence,
+ proximity_score=ocr.proximity_score,
+ id_method=id_method,
+ source_image=str(dest),
+ needs_review=needs_review,
+ review_note=review_note,
+ )
+ logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name)
+
+
async def process_existing() -> None:
"""Behandle bilder som allerede ligger i depot/ ved oppstart."""
for path in sorted(DEPOT_DIR.glob("*")):
diff --git a/backend/passage_log.py b/backend/passage_log.py
index 172502f..91b365d 100644
--- a/backend/passage_log.py
+++ b/backend/passage_log.py
@@ -50,6 +50,7 @@ async def _find_active_passage(
async def log_passage(
db: aiosqlite.Connection,
*,
+ race_id: Optional[str] = None,
profile_id: Optional[str],
bib_number: Optional[str],
station: str,
@@ -112,14 +113,14 @@ async def log_passage(
await db.execute(
"""
INSERT INTO passages (
- passage_id, profile_id, bib_number, station,
+ passage_id, race_id, profile_id, bib_number, station,
timestamp_utc, gps_lat, gps_lon, gps_alt,
confidence, id_method, source_image,
needs_review, review_note
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
- passage_id, profile_id, bib_number, station,
+ passage_id, race_id, profile_id, bib_number, station,
timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt,
confidence, id_method, source_image,
int(needs_review), review_note,
@@ -153,12 +154,16 @@ async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[
async def get_passages(
db: aiosqlite.Connection,
+ race_id: Optional[str] = None,
profile_id: Optional[str] = None,
station: Optional[str] = None,
needs_review: Optional[bool] = None,
) -> list[dict]:
clauses = []
params = []
+ if race_id is not None:
+ clauses.append("p.race_id = ?")
+ params.append(race_id)
if profile_id is not None:
clauses.append("p.profile_id = ?")
params.append(profile_id)
diff --git a/backend/profile_db.py b/backend/profile_db.py
index d65d7f9..5e6b90c 100644
--- a/backend/profile_db.py
+++ b/backend/profile_db.py
@@ -25,6 +25,7 @@ async def init_db(db: aiosqlite.Connection) -> None:
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,
@@ -50,12 +51,16 @@ async def init_db(db: aiosqlite.Connection) -> None:
);
CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id);
+ CREATE INDEX IF NOT EXISTS idx_passages_race ON passages(race_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()
+ from race_db import init_race_tables
+ await init_race_tables(db)
+
async def get_db() -> aiosqlite.Connection:
db = await aiosqlite.connect(DB_PATH)
diff --git a/backend/race_db.py b/backend/race_db.py
new file mode 100644
index 0000000..446cf14
--- /dev/null
+++ b/backend/race_db.py
@@ -0,0 +1,202 @@
+"""
+CRUD for løp (races) og stasjoner (stations).
+"""
+
+import uuid
+from typing import Optional
+
+import aiosqlite
+
+
+async def init_race_tables(db: aiosqlite.Connection) -> None:
+ await db.executescript("""
+ CREATE TABLE IF NOT EXISTS races (
+ race_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ date TEXT,
+ description TEXT,
+ is_active INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS stations (
+ station_id TEXT PRIMARY KEY,
+ race_id TEXT NOT NULL REFERENCES races(race_id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ station_order INTEGER NOT NULL DEFAULT 0,
+ gps_lat REAL,
+ gps_lon REAL,
+ gps_alt REAL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(race_id, name)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order);
+ """)
+ await db.commit()
+
+
+# ── Løp ──────────────────────────────────────────────────────────────────────
+
+async def create_race(
+ db: aiosqlite.Connection,
+ name: str,
+ date: Optional[str] = None,
+ description: Optional[str] = None,
+) -> dict:
+ race_id = str(uuid.uuid4())
+ await db.execute(
+ "INSERT INTO races (race_id, name, date, description) VALUES (?, ?, ?, ?)",
+ (race_id, name, date, description),
+ )
+ # Sett som aktivt hvis det er første løpet
+ async with db.execute("SELECT COUNT(*) FROM races") as cur:
+ count = (await cur.fetchone())[0]
+ if count == 1:
+ await db.execute("UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,))
+
+ # Opprett standard start- og målstasjon
+ for order, (sname, display) in enumerate([("start", "Start"), ("finish", "Mål")]):
+ await db.execute(
+ """INSERT INTO stations (station_id, race_id, name, display_name, station_order)
+ VALUES (?, ?, ?, ?, ?)""",
+ (str(uuid.uuid4()), race_id, sname, display, order * 1000),
+ )
+
+ await db.commit()
+ return await get_race(db, race_id)
+
+
+async def get_race(db: aiosqlite.Connection, race_id: str) -> Optional[dict]:
+ async with db.execute("SELECT * FROM races WHERE race_id = ?", (race_id,)) as cur:
+ row = await cur.fetchone()
+ return dict(row) if row else None
+
+
+async def list_races(db: aiosqlite.Connection) -> list[dict]:
+ async with db.execute("SELECT * FROM races ORDER BY date DESC, created_at DESC") as cur:
+ rows = await cur.fetchall()
+ return [dict(r) for r in rows]
+
+
+async def update_race(
+ db: aiosqlite.Connection,
+ race_id: str,
+ name: str,
+ date: Optional[str],
+ description: Optional[str],
+) -> bool:
+ cur = await db.execute(
+ "UPDATE races SET name = ?, date = ?, description = ? WHERE race_id = ?",
+ (name, date, description, race_id),
+ )
+ await db.commit()
+ return cur.rowcount > 0
+
+
+async def delete_race(db: aiosqlite.Connection, race_id: str) -> bool:
+ cur = await db.execute("DELETE FROM races WHERE race_id = ?", (race_id,))
+ await db.commit()
+ return cur.rowcount > 0
+
+
+async def set_active_race(db: aiosqlite.Connection, race_id: str) -> bool:
+ await db.execute("UPDATE races SET is_active = 0")
+ cur = await db.execute(
+ "UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,)
+ )
+ await db.commit()
+ return cur.rowcount > 0
+
+
+async def get_active_race(db: aiosqlite.Connection) -> Optional[dict]:
+ async with db.execute("SELECT * FROM races WHERE is_active = 1 LIMIT 1") as cur:
+ row = await cur.fetchone()
+ return dict(row) if row else None
+
+
+# ── Stasjoner ─────────────────────────────────────────────────────────────────
+
+async def list_stations(db: aiosqlite.Connection, race_id: str) -> list[dict]:
+ async with db.execute(
+ "SELECT * FROM stations WHERE race_id = ? ORDER BY station_order",
+ (race_id,),
+ ) as cur:
+ rows = await cur.fetchall()
+ return [dict(r) for r in rows]
+
+
+async def get_station(db: aiosqlite.Connection, station_id: str) -> Optional[dict]:
+ async with db.execute(
+ "SELECT * FROM stations WHERE station_id = ?", (station_id,)
+ ) as cur:
+ row = await cur.fetchone()
+ return dict(row) if row else None
+
+
+async def create_station(
+ db: aiosqlite.Connection,
+ race_id: str,
+ name: str,
+ display_name: str,
+ station_order: int,
+ gps_lat: Optional[float] = None,
+ gps_lon: Optional[float] = None,
+ gps_alt: Optional[float] = None,
+) -> dict:
+ station_id = str(uuid.uuid4())
+ await db.execute(
+ """INSERT INTO stations
+ (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt),
+ )
+ await db.commit()
+ return dict((await db.execute(
+ "SELECT * FROM stations WHERE station_id = ?", (station_id,)
+ )).get_cursor() or {})
+
+
+async def upsert_station(
+ db: aiosqlite.Connection,
+ race_id: str,
+ name: str,
+ display_name: str,
+ station_order: int,
+ gps_lat: Optional[float] = None,
+ gps_lon: Optional[float] = None,
+ gps_alt: Optional[float] = None,
+ station_id: Optional[str] = None,
+) -> dict:
+ if station_id:
+ await db.execute(
+ """UPDATE stations SET name=?, display_name=?, station_order=?,
+ gps_lat=?, gps_lon=?, gps_alt=? WHERE station_id=?""",
+ (name, display_name, station_order, gps_lat, gps_lon, gps_alt, station_id),
+ )
+ else:
+ station_id = str(uuid.uuid4())
+ await db.execute(
+ """INSERT INTO stations
+ (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt),
+ )
+ await db.commit()
+ async with db.execute("SELECT * FROM stations WHERE station_id = ?", (station_id,)) as cur:
+ row = await cur.fetchone()
+ return dict(row)
+
+
+async def delete_station(db: aiosqlite.Connection, station_id: str) -> bool:
+ # Ikke tillat sletting av start/finish
+ async with db.execute(
+ "SELECT name FROM stations WHERE station_id = ?", (station_id,)
+ ) as cur:
+ row = await cur.fetchone()
+ if row and row["name"] in ("start", "finish"):
+ return False
+ cur = await db.execute("DELETE FROM stations WHERE station_id = ?", (station_id,))
+ await db.commit()
+ return cur.rowcount > 0
diff --git a/backend/requirements.txt b/backend/requirements.txt
index ad7804e..c82f817 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -2,7 +2,7 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0
pillow==11.1.0
piexif==1.1.3
-easyocr==1.7.2
+pytesseract==0.3.13
opencv-python-headless==4.11.0.86
python-multipart==0.0.20
watchdog==6.0.0
diff --git a/backend/results.py b/backend/results.py
index 72d5edc..90bfcd1 100644
--- a/backend/results.py
+++ b/backend/results.py
@@ -7,21 +7,22 @@ from typing import Optional
import aiosqlite
-
-async def get_results(db: aiosqlite.Connection) -> list[dict]:
+async def get_results(db: aiosqlite.Connection, race_id: Optional[str] = None) -> list[dict]:
"""
Hent totalresultat for alle utøvere som har passert start og mål.
Returnerer sortert liste med split-tider.
"""
- # Hent alle bekreftede passeringer gruppert per utøver
- async with db.execute("""
+ race_filter = "AND p.race_id = ?" if race_id else ""
+ params = [race_id] if race_id else []
+
+ async with db.execute(f"""
SELECT p.profile_id, p.bib_number, a.name, a.club,
p.station, p.timestamp_utc
FROM passages p
LEFT JOIN athletes a ON a.profile_id = p.profile_id
- WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL
+ WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL {race_filter}
ORDER BY p.profile_id, p.timestamp_utc
- """) as cur:
+ """, params) as cur:
rows = await cur.fetchall()
# Grupper per utøver
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 1b249b8..4d912b9 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,36 +1,52 @@
+import { useEffect, useState } from 'react'
import { NavLink, Route, Routes } from 'react-router-dom'
+import { api } from './api.js'
import StartlistPage from './pages/StartlistPage.jsx'
import ReviewPage from './pages/ReviewPage.jsx'
import ResultsPage from './pages/ResultsPage.jsx'
import PassagesPage from './pages/PassagesPage.jsx'
import UploadPage from './pages/UploadPage.jsx'
+import RacePage from './pages/RacePage.jsx'
import './App.css'
-function Nav() {
+function Nav({ activeRace }) {
const linkClass = ({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')
return (
)
}
export default function App() {
+ const [activeRace, setActiveRace] = useState(null)
+
+ useEffect(() => {
+ api.getActiveRace().then(r => setActiveRace(r?.race_id ? r : null))
+ }, [])
+
return (
<>
-
+
{station.name}{race.description}
} +| ID | +Navn | +Lat | +Lon | +Alt (m) | ++ |
|---|
Ingen løp registrert ennå.
- JPEG og PNG støttes. Bildene legges i depot og behandles automatisk (EXIF valideres, OCR kjøres). -
++ Velg løp og stasjon for å laste opp. +
+ )}| Filnavn | -Størrelse | -Status | -
|---|---|---|
| Filnavn | Størrelse | Status |