from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from typing import Optional from datetime import datetime from app.core.database import get_db from app.core.security import get_current_user from app.core.config import settings from app.models.user import User, GarminConnectConfig router = APIRouter() class GarminConfigIn(BaseModel): email: str password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing. sync_enabled: bool = True sync_activities: bool = True sync_wellness: bool = True sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time class GarminConfigOut(BaseModel): email: str sync_enabled: bool sync_activities: bool sync_wellness: bool sync_lookback_days: int sync_interval_minutes: int # how often the automatic sync runs last_sync_at: Optional[datetime] last_sync_status: Optional[str] connected: bool class Config: from_attributes = True def _wants_more_history(old: int, new: int) -> bool: """True if `new` lookback requests older data than `old` (-1 = all-time).""" if new == old: return False if new == -1: # all-time requested where it wasn't before return True if old == -1: # was all-time, now finite → narrower, not more return False return new > old @router.get("/config", response_model=GarminConfigOut) async def get_config( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) ) cfg = result.scalar_one_or_none() if not cfg: return GarminConfigOut( email="", sync_enabled=False, sync_activities=True, sync_wellness=True, sync_lookback_days=30, sync_interval_minutes=settings.garmin_sync_interval_minutes, last_sync_at=None, last_sync_status=None, connected=False, ) return GarminConfigOut( email=cfg.email, sync_enabled=cfg.sync_enabled, sync_activities=cfg.sync_activities, sync_wellness=cfg.sync_wellness, 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, connected=True, ) @router.put("/config", response_model=GarminConfigOut) async def save_config( body: GarminConfigIn, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Save Garmin Connect settings. If a password is provided, re-authenticates and refreshes the stored OAuth token. If no password is provided, only updates the non-credential settings (toggles, lookback days) without re-logging in. """ from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin result = await db.execute( select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) ) cfg = result.scalar_one_or_none() if body.password: # Credentials update — test-login before saving enc = encrypt_password(body.password) try: garmin, token_store = authenticate_garmin(body.email, enc, None) except Exception as exc: raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}") if cfg: cfg.email = body.email cfg.password_enc = enc cfg.token_store = token_store cfg.last_sync_status = "Credentials updated" else: cfg = GarminConnectConfig( user_id=current_user.id, email=body.email, password_enc=enc, token_store=token_store, last_sync_status="Connected", ) db.add(cfg) else: # Settings-only update — password unchanged if not cfg: raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup") # If the user is now asking for MORE history than before, reset last_sync_at so # the next sync treats it as a first sync and does a one-time backfill of the # wider lookback window (then resumes cheap incremental syncs). Scheduled syncs # otherwise only refresh the last day or two, so without this an increased # lookback would never actually fetch the older data. old_lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30 if _wants_more_history(old_lookback, body.sync_lookback_days): cfg.last_sync_at = None cfg.last_sync_status = "Lookback increased — backfill on next sync" cfg.sync_enabled = body.sync_enabled cfg.sync_activities = body.sync_activities cfg.sync_wellness = body.sync_wellness cfg.sync_lookback_days = body.sync_lookback_days await db.commit() await db.refresh(cfg) return GarminConfigOut( email=cfg.email, sync_enabled=cfg.sync_enabled, sync_activities=cfg.sync_activities, sync_wellness=cfg.sync_wellness, 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, connected=True, ) @router.delete("/config") async def delete_config( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) ) cfg = result.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), ): """Enqueue an immediate Garmin Connect sync for this user.""" result = await db.execute( select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id) ) cfg = result.scalar_one_or_none() if not cfg or not cfg.sync_enabled: raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled") from app.workers.tasks import sync_garmin_connect_user task = sync_garmin_connect_user.delay(current_user.id) return {"task_id": task.id, "status": "queued"}