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"}