Files
MileVault/backend/app/api/garmin_sync.py
T
owain 04689a29bd
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 9s
Cut Garmin sync API volume; dashboard/health/records/UI improvements
Garmin Connect sync:
- Incremental syncs now re-fetch only a 1-day buffer (yesterday + today)
  instead of the full lookback window every run. Full lookback applies on
  the first sync only. Cuts steady-state API calls ~10x.
- Beat interval is now configurable via GARMIN_SYNC_INTERVAL_MINUTES and
  surfaced to the UI; the sync toggle is relabelled to the real cadence.

Frontend:
- Collapsible sidebar; clearer logged-in user + role display.
- Unified Body Battery colouring between dashboard and health (shared util).
- Sleep score trend chart on health page.
- Segments + medals on the dashboard's most-recent activity.
- Segments tab on the Records page.

Repo hygiene: add .gitignore, untrack committed __pycache__/*.pyc.

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

166 lines
5.6 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
@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")
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"}