Files
MileVault/backend/app/api/garmin_sync.py
T
owain bdd5f80c7e
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s
Harden auth/upload, fix PR-delete cascade and sync backfill
- OIDC: require signed short-lived state on login callback; reject
  missing userinfo sub (account-takeover guard); validate token
  exchange + userinfo responses
- Upload: safe zip extraction (path-traversal + zip-bomb cap),
  streamed size-capped writes, sanitised filenames
- Garmin: increasing lookback resets last_sync_at for one-time backfill
- Activities: delete/reprocess remove PersonalRecord rows (no FK cascade)
- Profile: validate /weight limit; sync lookback UI copy
- Dashboard: sleep shading uses same day as charted body battery

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:24:24 +01:00

187 lines
6.7 KiB
Python

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