Files
MileVault/backend/app/api/strava_sync.py
T
owain 07139120df
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s
feat: Strava support — bulk-export .gz/.tcx import fix + .tcx parser; full Strava API OAuth live sync (activities via streams) with Profile connect UI; colour gym/no-GPS sports red
2026-06-21 10:18:09 +01:00

272 lines
9.1 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from datetime import datetime, timezone, timedelta
from urllib.parse import urlencode
from jose import jwt, JWTError
from app.core.database import get_db
from app.core.security import get_current_user, create_access_token
from app.core.config import settings
from app.models.user import User, StravaConfig
from app.services.strava_sync import (
STRAVA_AUTHORIZE_URL, STRAVA_SCOPE, exchange_code, encrypt_token,
)
router = APIRouter()
STATE_PURPOSE = "strava-link"
def _redis_client():
import redis as redis_lib
return redis_lib.Redis.from_url(settings.redis_url)
def sync_task_key(user_id: int) -> str:
return f"strava_sync_task:{user_id}"
def sync_cancel_key(user_id: int) -> str:
return f"strava_sync_cancel:{user_id}"
def _redirect_uri() -> str:
return f"{settings.base_url.rstrip('/')}/api/strava-sync/callback"
class StravaConfigIn(BaseModel):
sync_enabled: bool = True
sync_lookback_days: int = 30
class StravaConfigOut(BaseModel):
connected: bool
athlete_name: Optional[str] = None
sync_enabled: bool = False
sync_lookback_days: int = 30
sync_interval_minutes: int = settings.garmin_sync_interval_minutes
last_sync_at: Optional[datetime] = None
last_sync_status: Optional[str] = None
configured: bool = True # whether the server has Strava API credentials at all
def _out(cfg: Optional[StravaConfig]) -> StravaConfigOut:
configured = bool(settings.strava_client_id and settings.strava_client_secret)
if not cfg:
return StravaConfigOut(
connected=False, sync_enabled=False, sync_lookback_days=30,
sync_interval_minutes=settings.garmin_sync_interval_minutes,
configured=configured,
)
return StravaConfigOut(
connected=True,
athlete_name=cfg.athlete_name,
sync_enabled=cfg.sync_enabled,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
sync_interval_minutes=settings.garmin_sync_interval_minutes,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
configured=configured,
)
@router.get("/config", response_model=StravaConfigOut)
async def get_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
cfg = (await db.execute(
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
)).scalar_one_or_none()
return _out(cfg)
@router.get("/authorize")
async def authorize(current_user: User = Depends(get_current_user)):
"""Return the Strava OAuth URL to redirect the browser to. A signed `state`
carries the user id through the callback (which has no JWT header)."""
if not (settings.strava_client_id and settings.strava_client_secret):
raise HTTPException(status_code=400, detail="Strava API is not configured on this server")
state = create_access_token(
{"sub": str(current_user.id), "purpose": STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
params = {
"client_id": settings.strava_client_id,
"response_type": "code",
"redirect_uri": _redirect_uri(),
"approval_prompt": "auto",
"scope": STRAVA_SCOPE,
"state": state,
}
return {"url": f"{STRAVA_AUTHORIZE_URL}?{urlencode(params)}"}
@router.get("/callback")
async def callback(
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
scope: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""Strava redirects here after the user authorizes. Exchange the code, store
tokens, then bounce back to the SPA profile page."""
profile_url = f"{settings.base_url.rstrip('/')}/profile"
if error or not code:
return RedirectResponse(f"{profile_url}?strava=error")
# Validate state → user id
try:
payload = jwt.decode(state or "", settings.secret_key, algorithms=[settings.algorithm])
if payload.get("purpose") != STATE_PURPOSE:
raise ValueError("bad purpose")
user_id = int(payload["sub"])
except (JWTError, KeyError, TypeError, ValueError):
return RedirectResponse(f"{profile_url}?strava=error")
# Require activity:read_all so private activities sync too.
if scope and "activity:read_all" not in scope:
return RedirectResponse(f"{profile_url}?strava=scope")
try:
tok = exchange_code(code)
except Exception:
return RedirectResponse(f"{profile_url}?strava=error")
athlete = tok.get("athlete") or {}
athlete_name = " ".join(
x for x in [athlete.get("firstname"), athlete.get("lastname")] if x
).strip() or (athlete.get("username") or None)
cfg = (await db.execute(
select(StravaConfig).where(StravaConfig.user_id == user_id)
)).scalar_one_or_none()
expires_at = datetime.fromtimestamp(tok["expires_at"], tz=timezone.utc)
if cfg:
cfg.access_token_enc = encrypt_token(tok["access_token"])
cfg.refresh_token_enc = encrypt_token(tok["refresh_token"])
cfg.expires_at = expires_at
cfg.athlete_id = str(athlete.get("id") or "") or cfg.athlete_id
cfg.athlete_name = athlete_name or cfg.athlete_name
cfg.last_sync_status = "Connected"
else:
cfg = StravaConfig(
user_id=user_id,
athlete_id=str(athlete.get("id") or "") or None,
athlete_name=athlete_name,
access_token_enc=encrypt_token(tok["access_token"]),
refresh_token_enc=encrypt_token(tok["refresh_token"]),
expires_at=expires_at,
sync_enabled=True,
sync_lookback_days=30,
last_sync_status="Connected",
)
db.add(cfg)
await db.commit()
# Kick off an initial sync immediately.
try:
from app.workers.tasks import sync_strava_user
sync_strava_user.delay(user_id)
except Exception:
pass
return RedirectResponse(f"{profile_url}?strava=connected")
@router.put("/config", response_model=StravaConfigOut)
async def save_config(
body: StravaConfigIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
cfg = (await db.execute(
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
)).scalar_one_or_none()
if not cfg:
raise HTTPException(status_code=400, detail="Strava is not connected")
# Asking for more history than before → reset last_sync_at so the next sync
# backfills the wider window (mirrors the Garmin behaviour).
old = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
new = body.sync_lookback_days
wants_more = (new != old) and (new == -1 or (old != -1 and new > old))
if wants_more:
cfg.last_sync_at = None
cfg.last_sync_status = "Lookback increased — backfill on next sync"
cfg.sync_enabled = body.sync_enabled
cfg.sync_lookback_days = body.sync_lookback_days
await db.commit()
await db.refresh(cfg)
return _out(cfg)
@router.delete("/config")
async def delete_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
cfg = (await db.execute(
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
)).scalar_one_or_none()
if cfg:
await db.delete(cfg)
await db.commit()
return {"status": "ok"}
@router.post("/trigger")
async def trigger_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
cfg = (await db.execute(
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
)).scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
raise HTTPException(status_code=400, detail="Strava sync is not configured or disabled")
from app.workers.tasks import sync_strava_user
task = sync_strava_user.delay(current_user.id)
try:
r = _redis_client()
r.delete(sync_cancel_key(current_user.id))
r.set(sync_task_key(current_user.id), task.id, ex=3600)
except Exception:
pass
return {"task_id": task.id, "status": "queued"}
@router.post("/cancel")
async def cancel_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.workers.tasks import celery_app
try:
r = _redis_client()
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
task_id = r.get(sync_task_key(current_user.id))
if task_id:
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
celery_app.control.revoke(tid, terminate=False)
except Exception:
pass
cfg = (await db.execute(
select(StravaConfig).where(StravaConfig.user_id == current_user.id)
)).scalar_one_or_none()
if cfg:
cfg.last_sync_status = "Cancelling…"
await db.commit()
return {"status": "cancelling"}