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"}
|
||||
@@ -74,6 +74,33 @@ def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
|
||||
return extracted
|
||||
|
||||
|
||||
def _gunzip(path: Path) -> Path | None:
|
||||
"""Decompress a .gz member to a sibling file without the .gz suffix,
|
||||
enforcing the same uncompressed-size cap. Returns the new path, or None on
|
||||
failure. The .gz is removed once expanded."""
|
||||
import gzip
|
||||
out_path = path.with_suffix("") # strips the trailing .gz
|
||||
try:
|
||||
total = 0
|
||||
with gzip.open(path, "rb") as src, open(out_path, "wb") as out:
|
||||
while True:
|
||||
chunk = src.read(_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_EXTRACT_SIZE:
|
||||
out.close()
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
out.write(chunk)
|
||||
except (OSError, EOFError):
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
return out_path
|
||||
|
||||
|
||||
@router.post("/activity")
|
||||
async def upload_activity(
|
||||
file: UploadFile = File(...),
|
||||
@@ -81,10 +108,10 @@ async def upload_activity(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a single .fit or .gpx activity file."""
|
||||
"""Upload a single .fit, .gpx or .tcx activity file."""
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".fit", ".gpx"}:
|
||||
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
||||
if suffix not in {".fit", ".gpx", ".tcx"}:
|
||||
raise HTTPException(status_code=400, detail="Only .fit, .gpx and .tcx files are supported")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||
dest = save_upload(file, dest_dir)
|
||||
@@ -184,15 +211,24 @@ async def upload_strava_export(
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
|
||||
for path in extracted:
|
||||
# Strava compresses most exported activities as <id>.fit.gz / .gpx.gz /
|
||||
# .tcx.gz. Decompress those to their underlying file first, otherwise the
|
||||
# gzipped majority of the archive is silently skipped.
|
||||
if path.suffix.lower() == ".gz":
|
||||
inner = _gunzip(path)
|
||||
if inner is None:
|
||||
continue
|
||||
path = inner
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in (".fit", ".gpx"):
|
||||
if suffix in (".fit", ".gpx", ".tcx"):
|
||||
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
|
||||
task_ids.append(task.id)
|
||||
|
||||
if not task_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No activity files (.fit or .gpx) found in this Strava archive",
|
||||
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user