Add multi-user support: Authelia OIDC, SQLite, Garage S3
- Auth via Authelia OIDC (public client + PKCE) with signed session cookies - SQLite DB for users, rides, and insights (SQLAlchemy) - Garage S3 for GPX file storage (boto3) - All API endpoints scoped to authenticated user - Dockerfile + docker-compose.yml with Garage service - Frontend auth check: redirects to /auth/login on 401, shows user badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,11 +9,18 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from garminconnect import Garmin, GarminConnectAuthenticationError
|
||||
from openai import OpenAI
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from auth import require_user, router as auth_router
|
||||
from db import Insight, Ride, User, create_tables, engine
|
||||
from storage import download_gpx, upload_gpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
@@ -24,19 +31,18 @@ for _noisy in ("httpx", "httpcore", "openai"):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
log = logging.getLogger("bikeslop")
|
||||
|
||||
GPX_DIR = Path(__file__).parent / "garmin_gpx_exports"
|
||||
INSIGHTS_FILE = Path(__file__).parent / "insights.json"
|
||||
LLAMACPP_BASE_URL = os.getenv("LLAMACPP_BASE_URL", "http://localhost:8080/v1")
|
||||
MODEL_NAME = "local-model" # overwritten at startup by auto-discovery
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
|
||||
MODEL_NAME = "local-model"
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||||
app.include_router(auth_router)
|
||||
|
||||
_insights_cache: dict[str, str] = {}
|
||||
_mfa_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _save_insights() -> None:
|
||||
INSIGHTS_FILE.write_text(json.dumps(_insights_cache, indent=2, ensure_ascii=False))
|
||||
_user_rides_cache: dict[str, dict[str, dict]] = {}
|
||||
_points_cache: dict[str, list[dict]] = {}
|
||||
_POINTS_CACHE_MAX = 50
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPX parsing helpers
|
||||
@@ -49,7 +55,6 @@ NS = {
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in metres between two GPS coordinates."""
|
||||
R = 6_371_000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
@@ -58,12 +63,13 @@ def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def parse_gpx(path: Path) -> dict:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
def parse_gpx_bytes(data: bytes, filename: str) -> dict:
|
||||
"""Parse GPX bytes into a ride dict including 'points'. No 'id' field."""
|
||||
root = ET.fromstring(data)
|
||||
|
||||
name_el = root.find(".//gpx:trk/gpx:name", NS)
|
||||
name = name_el.text if name_el is not None else path.stem
|
||||
stem = Path(filename).stem
|
||||
name = name_el.text if name_el is not None else stem
|
||||
type_el = root.find(".//gpx:trk/gpx:type", NS)
|
||||
activity_type = type_el.text if type_el is not None else "cycling"
|
||||
|
||||
@@ -81,7 +87,6 @@ def parse_gpx(path: Path) -> dict:
|
||||
hr = int(hr_el.text) if hr_el is not None else None
|
||||
points.append({"lat": lat, "lon": lon, "ele": ele, "time": ts, "hr": hr})
|
||||
|
||||
# Compute stats
|
||||
distance_m = 0.0
|
||||
ele_gain = 0.0
|
||||
ele_loss = 0.0
|
||||
@@ -105,10 +110,8 @@ def parse_gpx(path: Path) -> dict:
|
||||
hrs = [p["hr"] for p in points if p["hr"] is not None]
|
||||
avg_hr = round(sum(hrs) / len(hrs)) if hrs else None
|
||||
max_hr = max(hrs) if hrs else None
|
||||
|
||||
avg_speed_kmh = (distance_m / 1000) / (duration_s / 3600) if duration_s > 0 else 0
|
||||
|
||||
# HR zone distribution (% of points)
|
||||
zone_counts = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
|
||||
for h in hrs:
|
||||
if h < 120:
|
||||
@@ -127,13 +130,10 @@ def parse_gpx(path: Path) -> dict:
|
||||
for k, v in zone_counts.items()
|
||||
}
|
||||
|
||||
ride_id = path.stem # use filename without extension as ID
|
||||
|
||||
return {
|
||||
"id": ride_id,
|
||||
"name": name,
|
||||
"activity_type": activity_type,
|
||||
"filename": path.name,
|
||||
"filename": filename,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"end_time": end_time.isoformat() if end_time else None,
|
||||
"duration_s": duration_s,
|
||||
@@ -160,16 +160,64 @@ def parse_gpx(path: Path) -> dict:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load all rides at startup
|
||||
# Per-user ride + points cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_rides: dict[str, dict] = {}
|
||||
|
||||
def _load_user_rides(user_id: str) -> dict[str, dict]:
|
||||
with Session(engine) as s:
|
||||
rows = s.query(Ride).filter_by(user_id=user_id).all()
|
||||
return {r.id: r.to_dict() for r in rows}
|
||||
|
||||
|
||||
def get_user_rides_cached(user_id: str) -> dict[str, dict]:
|
||||
if user_id not in _user_rides_cache:
|
||||
_user_rides_cache[user_id] = _load_user_rides(user_id)
|
||||
return _user_rides_cache[user_id]
|
||||
|
||||
|
||||
def invalidate_user_cache(user_id: str) -> None:
|
||||
_user_rides_cache.pop(user_id, None)
|
||||
|
||||
|
||||
def _fetch_ride_points(ride_id: str, s3_key: str) -> list[dict]:
|
||||
if ride_id not in _points_cache:
|
||||
if len(_points_cache) >= _POINTS_CACHE_MAX:
|
||||
del _points_cache[next(iter(_points_cache))]
|
||||
gpx_data = download_gpx(s3_key)
|
||||
parsed = parse_gpx_bytes(gpx_data, s3_key.split("/")[-1])
|
||||
_points_cache[ride_id] = parsed["points"]
|
||||
return _points_cache[ride_id]
|
||||
|
||||
|
||||
def _get_ride_insight(ride_id: str) -> str | None:
|
||||
with Session(engine) as s:
|
||||
row = s.query(Insight).filter_by(ride_id=ride_id).first()
|
||||
return row.text if row else None
|
||||
|
||||
|
||||
def _save_ride_insight(ride_id: str, user_id: str, text: str) -> None:
|
||||
with Session(engine) as s:
|
||||
row = s.query(Insight).filter_by(ride_id=ride_id).first()
|
||||
if row:
|
||||
row.text = text
|
||||
else:
|
||||
row = Insight(ride_id=ride_id, user_id=user_id, text=text)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def load_rides():
|
||||
def startup():
|
||||
global MODEL_NAME
|
||||
# Auto-discover model name from llamacpp
|
||||
create_tables()
|
||||
log.info("Database tables ready.")
|
||||
|
||||
try:
|
||||
client = OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
|
||||
models = client.models.list()
|
||||
@@ -178,62 +226,50 @@ def load_rides():
|
||||
MODEL_NAME = first.id
|
||||
log.info("Discovered model: %s", MODEL_NAME)
|
||||
else:
|
||||
log.warning("No models returned from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME)
|
||||
log.warning("No models from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME)
|
||||
except Exception as exc:
|
||||
log.warning("Could not reach llamacpp at %s (%s) — using fallback '%s'", LLAMACPP_BASE_URL, exc, MODEL_NAME)
|
||||
|
||||
for gpx_file in sorted(GPX_DIR.glob("*.gpx"), reverse=True):
|
||||
try:
|
||||
ride = parse_gpx(gpx_file)
|
||||
_rides[ride["id"]] = ride
|
||||
except Exception as exc:
|
||||
log.error("Failed to parse %s: %s", gpx_file.name, exc)
|
||||
log.info("Loaded %d rides.", len(_rides))
|
||||
|
||||
if INSIGHTS_FILE.exists():
|
||||
try:
|
||||
_insights_cache.update(json.loads(INSIGHTS_FILE.read_text()))
|
||||
cached = sum(1 for rid in _insights_cache if rid in _rides)
|
||||
log.info("Loaded %d cached insights from %s", cached, INSIGHTS_FILE)
|
||||
except Exception as exc:
|
||||
log.warning("Could not load insights cache: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
def get_me(user: User = Depends(require_user)):
|
||||
return {"display_name": user.display_name, "email": user.email}
|
||||
|
||||
|
||||
@app.get("/api/rides")
|
||||
def list_rides():
|
||||
def list_rides(user: User = Depends(require_user)):
|
||||
summary_keys = [
|
||||
"id", "name", "activity_type", "start_time", "duration_s",
|
||||
"distance_km", "avg_speed_kmh", "elevation_gain_m",
|
||||
"avg_hr", "max_hr", "has_gps",
|
||||
]
|
||||
rides = []
|
||||
for r in _rides.values():
|
||||
rides.append({k: r[k] for k in summary_keys})
|
||||
# Sort by start_time descending
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
rides = [{k: r[k] for k in summary_keys} for r in rides_map.values()]
|
||||
rides.sort(key=lambda x: x["start_time"] or "", reverse=True)
|
||||
return rides
|
||||
|
||||
|
||||
@app.get("/api/rides/{ride_id}")
|
||||
def get_ride(ride_id: str):
|
||||
if ride_id not in _rides:
|
||||
def get_ride(ride_id: str, user: User = Depends(require_user)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
if ride_id not in rides_map:
|
||||
raise HTTPException(status_code=404, detail="Ride not found")
|
||||
r = _rides[ride_id]
|
||||
result = {k: v for k, v in r.items() if k != "points"}
|
||||
result["cached_insight"] = _insights_cache.get(ride_id)
|
||||
return result
|
||||
r = {k: v for k, v in rides_map[ride_id].items() if k != "points"}
|
||||
r["cached_insight"] = _get_ride_insight(ride_id)
|
||||
return r
|
||||
|
||||
|
||||
@app.get("/api/rides/{ride_id}/points")
|
||||
def get_ride_points(ride_id: str):
|
||||
if ride_id not in _rides:
|
||||
def get_ride_points(ride_id: str, user: User = Depends(require_user)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
if ride_id not in rides_map:
|
||||
raise HTTPException(status_code=404, detail="Ride not found")
|
||||
return _rides[ride_id]["points"]
|
||||
return _fetch_ride_points(ride_id, rides_map[ride_id]["s3_key"])
|
||||
|
||||
|
||||
def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict:
|
||||
@@ -248,7 +284,7 @@ def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict:
|
||||
]
|
||||
hrs = [p["hr"] for p in points if p.get("hr") is not None]
|
||||
if not hrs:
|
||||
return {l: 0 for l in labels}
|
||||
return {label: 0 for label in labels}
|
||||
counts = [0] * 5
|
||||
for h in hrs:
|
||||
if h < bounds[0]: counts[0] += 1
|
||||
@@ -263,27 +299,33 @@ def _llm_client() -> OpenAI:
|
||||
return OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed")
|
||||
|
||||
|
||||
def _get_previous_insight(ride: dict) -> str | None:
|
||||
"""Return the cached insight for the most recent ride before this one, or None."""
|
||||
ride_time = ride["start_time"]
|
||||
def _get_previous_insight_for_user(ride: dict, user_id: str) -> str | None:
|
||||
ride_time = ride.get("start_time")
|
||||
if not ride_time:
|
||||
return None
|
||||
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
|
||||
prior = sorted(
|
||||
[r for r in gps_rides if r["start_time"] < ride_time and r["id"] in _insights_cache],
|
||||
key=lambda r: r["start_time"],
|
||||
)
|
||||
if not prior:
|
||||
return None
|
||||
return _insights_cache[prior[-1]["id"]]
|
||||
with Session(engine) as s:
|
||||
row = s.execute(
|
||||
select(Insight.text)
|
||||
.join(Ride, Insight.ride_id == Ride.id)
|
||||
.where(and_(
|
||||
Ride.user_id == user_id,
|
||||
Ride.start_time < ride_time,
|
||||
Ride.has_gps == True,
|
||||
Ride.distance_km > 0,
|
||||
))
|
||||
.order_by(Ride.start_time.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _build_prompt(ride: dict, rhr: int, global_max_hr: int) -> str:
|
||||
def _build_prompt(ride: dict, user_id: str, rhr: int, global_max_hr: int, points: list[dict]) -> str:
|
||||
ride_time = ride["start_time"]
|
||||
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
|
||||
rides_map = get_user_rides_cached(user_id)
|
||||
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
|
||||
past_rides = [r for r in gps_rides if r["start_time"] < ride_time]
|
||||
|
||||
zones = _karvonen_zones(rhr, global_max_hr, ride["points"])
|
||||
zones = _karvonen_zones(rhr, global_max_hr, points)
|
||||
zone_str = ", ".join(f"{k}={v}%" for k, v in zones.items())
|
||||
duration_min = ride["duration_s"] // 60
|
||||
ride_date = ride_time[:10] if ride_time else "unknown"
|
||||
@@ -291,12 +333,12 @@ def _build_prompt(ride: dict, rhr: int, global_max_hr: int) -> str:
|
||||
if past_rides:
|
||||
avg_dist = sum(r["distance_km"] for r in past_rides) / len(past_rides)
|
||||
hr_list = [r["avg_hr"] for r in past_rides if r["avg_hr"]]
|
||||
avg_hr = sum(hr_list) / len(hr_list) if hr_list else 0
|
||||
history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr:.0f} bpm."
|
||||
avg_hr_val = sum(hr_list) / len(hr_list) if hr_list else 0
|
||||
history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr_val:.0f} bpm."
|
||||
else:
|
||||
history_str = "This is one of their earliest recorded rides — no prior history to compare against."
|
||||
|
||||
previous_insight = _get_previous_insight(ride)
|
||||
previous_insight = _get_previous_insight_for_user(ride, user_id)
|
||||
if previous_insight:
|
||||
prior_section = f"""
|
||||
Your feedback from their last ride:
|
||||
@@ -321,17 +363,19 @@ Then one blank line, then write exactly 3–4 short paragraphs in a warm, direct
|
||||
|
||||
|
||||
@app.post("/api/rides/{ride_id}/insights")
|
||||
def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
|
||||
if ride_id not in _rides:
|
||||
def post_insights(ride_id: str, user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
if ride_id not in rides_map:
|
||||
raise HTTPException(status_code=404, detail="Ride not found")
|
||||
ride = _rides[ride_id]
|
||||
ride = rides_map[ride_id]
|
||||
|
||||
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0]
|
||||
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0]
|
||||
global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200)
|
||||
prompt = _build_prompt(ride, rhr, global_max_hr)
|
||||
points = _fetch_ride_points(ride_id, ride["s3_key"])
|
||||
prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
|
||||
|
||||
client = _llm_client()
|
||||
log.info("POST /insights ride_id=%s rhr=%d", ride_id, rhr)
|
||||
log.info("POST /insights ride_id=%s rhr=%d user=%s", ride_id, rhr, user.id)
|
||||
log.debug("Prompt:\n%s", prompt)
|
||||
|
||||
def stream_response():
|
||||
@@ -356,8 +400,7 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
|
||||
accumulated.append(delta)
|
||||
yield delta
|
||||
log.info("Stream complete — %d chunks, %d chars", chunks_received, sum(len(c) for c in accumulated))
|
||||
_insights_cache[ride_id] = "".join(accumulated)
|
||||
_save_insights()
|
||||
_save_ride_insight(ride_id, user.id, "".join(accumulated))
|
||||
log.info("Saved insight for %s", ride_id)
|
||||
except Exception as exc:
|
||||
log.exception("Error streaming insights: %s", exc)
|
||||
@@ -367,24 +410,32 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)):
|
||||
|
||||
|
||||
@app.post("/api/insights/generate-all")
|
||||
def generate_all_insights(rhr: int = Query(60, ge=30, le=100)):
|
||||
gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
|
||||
def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)):
|
||||
rides_map = get_user_rides_cached(user.id)
|
||||
gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]]
|
||||
global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200)
|
||||
# Chronological order so time-aware context is always correct
|
||||
ordered = sorted(gps_rides, key=lambda r: r["start_time"])
|
||||
to_generate = [r for r in ordered if r["id"] not in _insights_cache]
|
||||
|
||||
with Session(engine) as s:
|
||||
insighted_ids = {
|
||||
row[0] for row in s.execute(
|
||||
select(Insight.ride_id).where(Insight.user_id == user.id)
|
||||
)
|
||||
}
|
||||
to_generate = [r for r in ordered if r["id"] not in insighted_ids]
|
||||
total = len(to_generate)
|
||||
log.info("generate-all: %d rides to generate (rhr=%d)", total, rhr)
|
||||
log.info("generate-all: %d rides to generate (rhr=%d user=%s)", total, rhr, user.id)
|
||||
|
||||
def sse_stream():
|
||||
client = _llm_client()
|
||||
llm = _llm_client()
|
||||
for idx, ride in enumerate(to_generate, 1):
|
||||
ride_id = ride["id"]
|
||||
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'generating'})}\n\n"
|
||||
log.info("Generating insight %d/%d: %s", idx, total, ride_id)
|
||||
try:
|
||||
prompt = _build_prompt(ride, rhr, global_max_hr)
|
||||
response = client.chat.completions.create(
|
||||
points = _fetch_ride_points(ride_id, ride["s3_key"])
|
||||
prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points)
|
||||
response = llm.chat.completions.create(
|
||||
model=MODEL_NAME,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
stream=False,
|
||||
@@ -393,8 +444,7 @@ def generate_all_insights(rhr: int = Query(60, ge=30, le=100)):
|
||||
)
|
||||
msg = response.choices[0].message.model_dump()
|
||||
text = msg.get("content") or msg.get("reasoning_content") or ""
|
||||
_insights_cache[ride_id] = text
|
||||
_save_insights()
|
||||
_save_ride_insight(ride_id, user.id, text)
|
||||
yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'done'})}\n\n"
|
||||
except Exception as exc:
|
||||
log.exception("Failed insight for %s: %s", ride_id, exc)
|
||||
@@ -410,10 +460,11 @@ def generate_all_insights(rhr: int = Query(60, ge=30, le=100)):
|
||||
|
||||
|
||||
@app.post("/api/fetch-rides")
|
||||
async def fetch_rides_from_garmin(request: Request):
|
||||
async def fetch_rides_from_garmin(request: Request, user: User = Depends(require_user)):
|
||||
body = await request.json()
|
||||
email = body.get("email", "")
|
||||
password = body.get("password", "")
|
||||
user_id = user.id
|
||||
|
||||
session_id = uuid.uuid4().hex
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
@@ -432,16 +483,23 @@ async def fetch_rides_from_garmin(request: Request):
|
||||
def garmin_worker():
|
||||
try:
|
||||
emit({"status": "auth"})
|
||||
client = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
|
||||
client.login()
|
||||
garmin = Garmin(email=email, password=password, prompt_mfa=prompt_mfa)
|
||||
garmin.login()
|
||||
|
||||
emit({"status": "fetching"})
|
||||
activities = client.get_activities(0, 50)
|
||||
activities = garmin.get_activities(0, 50)
|
||||
cycling = [
|
||||
a for a in activities
|
||||
if "cycling" in a.get("activityType", {}).get("typeKey", "")
|
||||
]
|
||||
|
||||
with Session(engine) as s:
|
||||
existing_filenames = {
|
||||
row[0] for row in s.execute(
|
||||
select(Ride.filename).where(Ride.user_id == user_id)
|
||||
)
|
||||
}
|
||||
|
||||
to_download = []
|
||||
for act in cycling:
|
||||
act_id = act["activityId"]
|
||||
@@ -450,18 +508,42 @@ async def fetch_rides_from_garmin(request: Request):
|
||||
safe_time = start.replace(":", "-").replace(" ", "_")
|
||||
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip()
|
||||
filename = f"{safe_time}_{safe_name}_{act_id}.gpx"
|
||||
if not (GPX_DIR / filename).exists():
|
||||
to_download.append((act_id, name, GPX_DIR / filename))
|
||||
if filename not in existing_filenames:
|
||||
to_download.append((act_id, name, filename))
|
||||
|
||||
skipped = len(cycling) - len(to_download)
|
||||
added = 0
|
||||
for idx, (act_id, name, filepath) in enumerate(to_download, 1):
|
||||
for idx, (act_id, name, filename) in enumerate(to_download, 1):
|
||||
emit({"status": "downloading", "index": idx, "total": len(to_download), "name": name})
|
||||
try:
|
||||
gpx_data = client.download_activity(act_id, dl_fmt=client.ActivityDownloadFormat.GPX)
|
||||
filepath.write_bytes(gpx_data)
|
||||
ride = parse_gpx(filepath)
|
||||
_rides[ride["id"]] = ride
|
||||
gpx_data = garmin.download_activity(act_id, dl_fmt=garmin.ActivityDownloadFormat.GPX)
|
||||
s3_key = upload_gpx(user_id, filename, gpx_data)
|
||||
parsed = parse_gpx_bytes(gpx_data, filename)
|
||||
ride_id = uuid.uuid4().hex
|
||||
with Session(engine) as s:
|
||||
s.add(Ride(
|
||||
id=ride_id,
|
||||
user_id=user_id,
|
||||
s3_key=s3_key,
|
||||
filename=filename,
|
||||
name=parsed["name"],
|
||||
activity_type=parsed["activity_type"],
|
||||
start_time=parsed["start_time"],
|
||||
end_time=parsed["end_time"],
|
||||
duration_s=parsed["duration_s"],
|
||||
distance_m=parsed["distance_m"],
|
||||
distance_km=parsed["distance_km"],
|
||||
avg_speed_kmh=parsed["avg_speed_kmh"],
|
||||
elevation_gain_m=parsed["elevation_gain_m"],
|
||||
elevation_loss_m=parsed["elevation_loss_m"],
|
||||
avg_hr=parsed["avg_hr"],
|
||||
max_hr=parsed["max_hr"],
|
||||
hr_zones_json=json.dumps(parsed["hr_zones"]),
|
||||
has_gps=parsed["has_gps"],
|
||||
))
|
||||
s.commit()
|
||||
_points_cache[ride_id] = parsed["points"]
|
||||
invalidate_user_cache(user_id)
|
||||
added += 1
|
||||
except Exception as exc:
|
||||
log.error("Failed to download activity %s: %s", act_id, exc)
|
||||
@@ -474,7 +556,7 @@ async def fetch_rides_from_garmin(request: Request):
|
||||
emit({"status": "error", "message": str(exc)})
|
||||
finally:
|
||||
_mfa_sessions.pop(session_id, None)
|
||||
emit(None) # sentinel to end the SSE stream
|
||||
emit(None)
|
||||
|
||||
async def generate():
|
||||
loop.run_in_executor(None, garmin_worker)
|
||||
|
||||
Reference in New Issue
Block a user