Files
MileVault/backend/app/api/garmin_sync.py
T
owain 6d224d51c5
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 8s
Add Garmin Connect auto-sync via python-garminconnect
- GarminConnectConfig model stores encrypted credentials and OAuth token
- garmin_connect_sync service: token-based auth with password fallback,
  activity FIT download + queue, daily wellness from JSON API
- Celery beat schedule: sync_all_garmin_connect fires every hour
- New API router /api/garmin-sync: config CRUD, manual trigger
- Beat container added to docker-compose.yml and docker-compose.deploy.yml
- ProfilePage: Garmin Connect section with connect/update/disconnect and Sync now

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:08:12 +01:00

154 lines
4.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.models.user import User, GarminConnectConfig
router = APIRouter()
class GarminConfigIn(BaseModel):
email: str
password: str # plaintext; encrypted before storage
sync_enabled: bool = True
sync_activities: bool = True
sync_wellness: bool = True
class GarminConfigOut(BaseModel):
email: str
sync_enabled: bool
sync_activities: bool
sync_wellness: bool
last_sync_at: Optional[datetime]
last_sync_status: Optional[str]
connected: bool # True when credentials exist
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, 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,
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 (or replace) Garmin Connect credentials.
Attempts a test login first so we can store the initial OAuth token and
fail fast on wrong credentials.
"""
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
enc = encrypt_password(body.password)
# Test login — raises HTTPException on auth failure
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}")
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
cfg.email = body.email
cfg.password_enc = enc
cfg.token_store = token_store
cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness
cfg.last_sync_status = "Credentials updated"
else:
cfg = GarminConnectConfig(
user_id=current_user.id,
email=body.email,
password_enc=enc,
token_store=token_store,
sync_enabled=body.sync_enabled,
sync_activities=body.sync_activities,
sync_wellness=body.sync_wellness,
last_sync_status="Connected",
)
db.add(cfg)
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,
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"}