Files
timing/backend/passage_log.py
T
steinhelge 5393e85a74
Build & Deploy / build-and-deploy (push) Successful in 2m18s
Add race and station management
- races table: name, date, description, is_active
- stations table: ordered checkpoints with GPS per race
- New /api/races and /api/races/{id}/stations endpoints
- Upload now requires race + station selection; uses station GPS
  so images without GPS EXIF are accepted
- passages filtered by active race throughout
- RacePage: create races, manage stations (add/edit/delete checkpoints)
- Navbar shows active race name
- Start and finish stations created automatically per race

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

214 lines
6.4 KiB
Python

"""
Skriv og query passeringslogg i SQLite.
Deduplisering:
Bilder av samme utøver (bib) ved samme stasjon innen DEDUP_WINDOW_SECONDS
grupperes under én passering. Alle bilder bevares i passage_images.
Passeringstidsstempel = siste bilde i burst-sekvensen (nyeste timestamp).
"""
import uuid
from datetime import datetime, timedelta
from typing import Optional
import aiosqlite
DEDUP_WINDOW_SECONDS = 2
async def _find_active_passage(
db: aiosqlite.Connection,
bib_number: str,
station: str,
timestamp_utc: datetime,
) -> Optional[dict]:
"""
Finn eksisterende passering med samme bib og stasjon der nytt bilde faller
innen DEDUP_WINDOW_SECONDS etter siste registrerte bilde i passeringen.
"""
async with db.execute(
"""
SELECT p.passage_id, MAX(pi.timestamp_utc) AS last_image_ts
FROM passages p
JOIN passage_images pi ON pi.passage_id = p.passage_id
WHERE p.bib_number = ? AND p.station = ?
GROUP BY p.passage_id
HAVING last_image_ts >= ?
ORDER BY last_image_ts DESC
LIMIT 1
""",
(
bib_number,
station,
(timestamp_utc - timedelta(seconds=DEDUP_WINDOW_SECONDS)).isoformat(),
),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def log_passage(
db: aiosqlite.Connection,
*,
race_id: Optional[str] = None,
profile_id: Optional[str],
bib_number: Optional[str],
station: str,
timestamp_utc: datetime,
gps_lat: float,
gps_lon: float,
gps_alt: Optional[float],
confidence: float,
proximity_score: float = 0.0,
id_method: str,
source_image: str,
needs_review: bool = False,
review_note: Optional[str] = None,
) -> str:
"""
Logg ett bilde som en passering.
- Er bildet del av en pågående burst (bib+stasjon innen vinduet)?
→ Legg til i passage_images, oppdater passeringstidsstempel til dette
bildet (siste i tid = offisiell passeringstid).
- Nytt bib/stasjon eller utenfor vinduet?
→ Opprett ny passering.
Returnerer passage_id.
"""
image_id = str(uuid.uuid4())
if bib_number:
existing = await _find_active_passage(db, bib_number, station, timestamp_utc)
if existing:
passage_id = existing["passage_id"]
# Legg til bilde i sekvensen
await db.execute(
"""
INSERT INTO passage_images (image_id, passage_id, image_path, timestamp_utc, proximity_score)
VALUES (?, ?, ?, ?, ?)
""",
(image_id, passage_id, source_image, timestamp_utc.isoformat(), proximity_score),
)
# Oppdater passeringen med siste bilde som offisiell tid og bildesti
await db.execute(
"""
UPDATE passages
SET timestamp_utc = ?, source_image = ?, confidence = ?, id_method = ?
WHERE passage_id = ?
""",
(
timestamp_utc.isoformat(),
source_image,
confidence,
id_method,
passage_id,
),
)
await db.commit()
return passage_id
# Ny passering
passage_id = str(uuid.uuid4())
await db.execute(
"""
INSERT INTO passages (
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
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,
),
)
await db.execute(
"""
INSERT INTO passage_images (image_id, passage_id, image_path, timestamp_utc, proximity_score)
VALUES (?, ?, ?, ?, ?)
""",
(image_id, passage_id, source_image, timestamp_utc.isoformat(), proximity_score),
)
await db.commit()
return passage_id
async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[dict]:
"""Hent alle bilder for en passering, kronologisk sortert."""
async with db.execute(
"""
SELECT image_id, image_path, timestamp_utc, proximity_score
FROM passage_images
WHERE passage_id = ?
ORDER BY timestamp_utc
""",
(passage_id,),
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
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)
if station is not None:
clauses.append("p.station = ?")
params.append(station)
if needs_review is not None:
clauses.append("p.needs_review = ?")
params.append(int(needs_review))
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
query = f"""
SELECT p.*, a.name, a.club
FROM passages p
LEFT JOIN athletes a ON a.profile_id = p.profile_id
{where}
ORDER BY p.timestamp_utc
"""
async with db.execute(query, params) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def resolve_passage(
db: aiosqlite.Connection,
passage_id: str,
profile_id: Optional[str],
bib_number: Optional[str],
review_note: Optional[str] = None,
) -> bool:
cur = await db.execute(
"""
UPDATE passages
SET profile_id = ?, bib_number = ?, needs_review = 0,
review_note = ?, id_method = 'manual'
WHERE passage_id = ?
""",
(profile_id, bib_number, review_note, passage_id),
)
await db.commit()
return cur.rowcount > 0
async def delete_passage(db: aiosqlite.Connection, passage_id: str) -> bool:
cur = await db.execute("DELETE FROM passages WHERE passage_id = ?", (passage_id,))
await db.commit()
return cur.rowcount > 0