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
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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"}
|
||||
Reference in New Issue
Block a user