272 lines
9.1 KiB
Python
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"}
|