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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
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"}
|
||||||
+2
-1
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
|
|
||||||
from app.core.database import engine, AsyncSessionLocal, Base
|
from app.core.database import engine, AsyncSessionLocal, Base
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api import auth, activities, routes, health, records, upload, profile
|
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
@@ -98,6 +98,7 @@ app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
|||||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||||
|
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ class User(Base):
|
|||||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||||
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class GarminConnectConfig(Base):
|
||||||
|
"""Per-user Garmin Connect credentials and sync state."""
|
||||||
|
__tablename__ = "garmin_connect_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||||
|
email = Column(String(256), nullable=False)
|
||||||
|
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
|
||||||
|
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
|
||||||
|
sync_enabled = Column(Boolean, default=True)
|
||||||
|
sync_activities = Column(Boolean, default=True)
|
||||||
|
sync_wellness = Column(Boolean, default=True)
|
||||||
|
last_sync_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_sync_status = Column(String(512), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="garmin_connect_config")
|
||||||
|
|
||||||
|
|
||||||
class WeightLog(Base):
|
class WeightLog(Base):
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Garmin Connect sync helpers.
|
||||||
|
|
||||||
|
authenticate_garmin() returns an authenticated client, refreshing the stored
|
||||||
|
OAuth token when possible and falling back to email/password re-login.
|
||||||
|
|
||||||
|
sync_activities() downloads new FIT files and queues them for processing.
|
||||||
|
sync_wellness() pulls daily stats/sleep/HRV summaries from the JSON API
|
||||||
|
and upserts them into health_metrics.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password encryption ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fernet():
|
||||||
|
import base64, hashlib
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from app.core.config import settings
|
||||||
|
key = base64.urlsafe_b64encode(hashlib.sha256(settings.secret_key.encode()).digest())
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_password(password: str) -> str:
|
||||||
|
return _fernet().encrypt(password.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_password(enc: str) -> str:
|
||||||
|
return _fernet().decrypt(enc.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str]) -> Tuple:
|
||||||
|
"""
|
||||||
|
Returns (garmin_client, new_token_store_or_None).
|
||||||
|
new_token_store is set only when tokens were refreshed/re-created so the
|
||||||
|
caller can persist them.
|
||||||
|
"""
|
||||||
|
import garminconnect
|
||||||
|
|
||||||
|
# Try stored OAuth token first (garth auto-refreshes access token on use)
|
||||||
|
if token_store:
|
||||||
|
try:
|
||||||
|
garmin = garminconnect.Garmin(
|
||||||
|
email=email, password=decrypt_password(password_enc)
|
||||||
|
)
|
||||||
|
garmin.garth.loads(token_store)
|
||||||
|
garmin.get_full_name() # lightweight request; triggers refresh if needed
|
||||||
|
return garmin, None # tokens still valid
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("Garmin token invalid (%s), re-authenticating", exc)
|
||||||
|
|
||||||
|
# Full login with email + password
|
||||||
|
garmin = garminconnect.Garmin(email=email, password=decrypt_password(password_enc))
|
||||||
|
garmin.login()
|
||||||
|
return garmin, garmin.garth.dumps()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Activity sync ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sync_activities(garmin, user_id: int, since: Optional[datetime],
|
||||||
|
db, file_store_path: str) -> int:
|
||||||
|
"""
|
||||||
|
List activities since `since` from Garmin Connect, skip any already in the
|
||||||
|
DB, download FIT ZIPs for new ones, and queue them for processing.
|
||||||
|
Returns the number of new activities queued.
|
||||||
|
"""
|
||||||
|
from app.workers.tasks import process_activity_file
|
||||||
|
from app.models.user import Activity
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
start_date = (since - timedelta(days=1)).date() if since else (date.today() - timedelta(days=30))
|
||||||
|
end_date = date.today()
|
||||||
|
|
||||||
|
try:
|
||||||
|
activities = garmin.get_activities_by_date(
|
||||||
|
start_date.isoformat(), end_date.isoformat()
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to list Garmin activities: %s", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
queued = 0
|
||||||
|
for act in activities:
|
||||||
|
garmin_id = str(act.get("activityId", "")).strip()
|
||||||
|
if not garmin_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if already imported (garmin_activity_id unique index)
|
||||||
|
existing = db.execute(
|
||||||
|
select(Activity).where(Activity.garmin_activity_id == garmin_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Download original FIT (wrapped in a ZIP by Garmin)
|
||||||
|
try:
|
||||||
|
zip_bytes = garmin.download_activity(
|
||||||
|
int(garmin_id),
|
||||||
|
dl_fmt=garmin.ActivityDownloadFormat.ORIGINAL,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to download activity %s: %s", garmin_id, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract the FIT file from the ZIP
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||||
|
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
|
||||||
|
if not fit_names:
|
||||||
|
logger.debug("No FIT in ZIP for activity %s", garmin_id)
|
||||||
|
continue
|
||||||
|
fit_data = zf.read(fit_names[0])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to unzip activity %s: %s", garmin_id, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Save to disk and queue
|
||||||
|
dest_dir = Path(file_store_path) / str(user_id) / "garmin_connect"
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = dest_dir / f"{garmin_id}.fit"
|
||||||
|
dest.write_bytes(fit_data)
|
||||||
|
|
||||||
|
process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
return queued
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wellness sync ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sync_wellness(garmin, user_id: int, since: Optional[datetime], db) -> int:
|
||||||
|
"""
|
||||||
|
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
|
||||||
|
day since `since` and upsert into health_metrics.
|
||||||
|
Returns the number of days upserted.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
start_date = since.date() if since else (date.today() - timedelta(days=7))
|
||||||
|
days = (date.today() - start_date).days + 1
|
||||||
|
processed = 0
|
||||||
|
|
||||||
|
for i in range(max(days, 1)):
|
||||||
|
day = start_date + timedelta(days=i)
|
||||||
|
day_str = day.isoformat()
|
||||||
|
|
||||||
|
stats = _safe(garmin.get_stats, day_str)
|
||||||
|
sleep_data = _safe(garmin.get_sleep_data, day_str)
|
||||||
|
hrv_data = _safe(garmin.get_hrv_data, day_str)
|
||||||
|
|
||||||
|
row = _parse_day(stats, sleep_data, hrv_data)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cols = list(row.keys())
|
||||||
|
col_sql = ", ".join(cols)
|
||||||
|
val_sql = ", ".join(f":{c}" for c in cols)
|
||||||
|
upd_sql = ", ".join(
|
||||||
|
# total_calories uses GREATEST so multiple sources don't downgrade
|
||||||
|
f"{c} = GREATEST(EXCLUDED.{c}, health_metrics.{c})"
|
||||||
|
if c == "total_calories" else
|
||||||
|
f"{c} = COALESCE(EXCLUDED.{c}, health_metrics.{c})"
|
||||||
|
for c in cols
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"user_id": user_id, "day": day.isoformat()}
|
||||||
|
params.update(row)
|
||||||
|
|
||||||
|
db.execute(text(f"""
|
||||||
|
INSERT INTO health_metrics (user_id, date, {col_sql})
|
||||||
|
VALUES (:user_id, :day, {val_sql})
|
||||||
|
ON CONFLICT (user_id, date) DO UPDATE SET {upd_sql}
|
||||||
|
"""), params)
|
||||||
|
db.commit()
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(fn, *args):
|
||||||
|
try:
|
||||||
|
return fn(*args)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("%s(%s) skipped: %s", fn.__name__, args, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
||||||
|
row = {}
|
||||||
|
|
||||||
|
if stats:
|
||||||
|
_set(row, "resting_hr", stats.get("restingHeartRate"))
|
||||||
|
_set(row, "steps", stats.get("totalSteps"))
|
||||||
|
_set(row, "floors_climbed", stats.get("floorsAscended"))
|
||||||
|
_set(row, "avg_stress", stats.get("averageStressLevel"))
|
||||||
|
active = stats.get("activeKilocalories")
|
||||||
|
bmr = stats.get("bmrKilocalories")
|
||||||
|
_set(row, "active_calories", active)
|
||||||
|
if active and bmr:
|
||||||
|
_set(row, "total_calories", float(active) + float(bmr))
|
||||||
|
|
||||||
|
if sleep_data:
|
||||||
|
dto = sleep_data.get("dailySleepDTO") or sleep_data
|
||||||
|
_set(row, "sleep_duration_s", dto.get("sleepTimeSeconds"))
|
||||||
|
_set(row, "sleep_deep_s", dto.get("deepSleepSeconds"))
|
||||||
|
_set(row, "sleep_light_s", dto.get("lightSleepSeconds"))
|
||||||
|
_set(row, "sleep_rem_s", dto.get("remSleepSeconds"))
|
||||||
|
_set(row, "sleep_awake_s", dto.get("awakeSleepSeconds"))
|
||||||
|
|
||||||
|
# Timestamps are milliseconds since epoch in local time
|
||||||
|
for key, col in (("sleepStartTimestampLocal", "sleep_start"),
|
||||||
|
("sleepEndTimestampLocal", "sleep_end")):
|
||||||
|
ms = dto.get(key)
|
||||||
|
if ms:
|
||||||
|
_set(row, col, datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat())
|
||||||
|
|
||||||
|
# SpO2
|
||||||
|
spo2 = dto.get("averageSpO2Value")
|
||||||
|
if spo2 and 50 < float(spo2) <= 100:
|
||||||
|
row["spo2_avg"] = float(spo2)
|
||||||
|
|
||||||
|
# Sleep score — structure varies across firmware
|
||||||
|
scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore")
|
||||||
|
if isinstance(scores, dict):
|
||||||
|
overall = scores.get("overall") or scores.get("qualityScore")
|
||||||
|
if isinstance(overall, dict):
|
||||||
|
_set(row, "sleep_score", overall.get("value"))
|
||||||
|
else:
|
||||||
|
_set(row, "sleep_score", overall)
|
||||||
|
elif isinstance(scores, (int, float)):
|
||||||
|
row["sleep_score"] = scores
|
||||||
|
|
||||||
|
if hrv_data:
|
||||||
|
summary = hrv_data.get("hrvSummary") or hrv_data
|
||||||
|
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg"))
|
||||||
|
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
|
||||||
|
status = summary.get("status")
|
||||||
|
if status:
|
||||||
|
row["hrv_status"] = str(status).lower()
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _set(d: dict, key: str, val):
|
||||||
|
if val is not None:
|
||||||
|
d[key] = val
|
||||||
@@ -22,6 +22,12 @@ celery_app.conf.update(
|
|||||||
enable_utc=True,
|
enable_utc=True,
|
||||||
task_track_started=True,
|
task_track_started=True,
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
|
beat_schedule={
|
||||||
|
"sync-garmin-connect-hourly": {
|
||||||
|
"task": "sync_all_garmin_connect",
|
||||||
|
"schedule": 3600.0, # every hour
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
WELLNESS_SUFFIXES = (
|
WELLNESS_SUFFIXES = (
|
||||||
@@ -46,7 +52,8 @@ def is_wellness_file(file_path: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="process_activity_file")
|
@celery_app.task(bind=True, name="process_activity_file")
|
||||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
|
||||||
|
garmin_activity_id: str = None):
|
||||||
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
|
||||||
|
|
||||||
if is_wellness_file(file_path):
|
if is_wellness_file(file_path):
|
||||||
@@ -110,6 +117,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=parsed["name"],
|
name=parsed["name"],
|
||||||
sport_type=parsed["sport_type"],
|
sport_type=parsed["sport_type"],
|
||||||
|
garmin_activity_id=garmin_activity_id,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
distance_m=parsed.get("distance_m"),
|
distance_m=parsed.get("distance_m"),
|
||||||
duration_s=parsed.get("duration_s"),
|
duration_s=parsed.get("duration_s"),
|
||||||
@@ -450,6 +458,83 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="sync_garmin_connect_user")
|
||||||
|
def sync_garmin_connect_user(user_id: int):
|
||||||
|
"""Sync Garmin Connect data (activities + wellness) for one user."""
|
||||||
|
from app.services.garmin_connect_sync import authenticate_garmin, sync_activities, sync_wellness
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import GarminConnectConfig
|
||||||
|
from app.core.config import settings
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
cfg = db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not cfg or not cfg.sync_enabled:
|
||||||
|
return {"status": "skipped"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
garmin, new_token = authenticate_garmin(cfg.email, cfg.password_enc, cfg.token_store)
|
||||||
|
except Exception as exc:
|
||||||
|
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
cfg.last_sync_status = f"Auth error: {exc}"
|
||||||
|
db.commit()
|
||||||
|
return {"status": "auth_error", "error": str(exc)}
|
||||||
|
|
||||||
|
if new_token:
|
||||||
|
cfg.token_store = new_token
|
||||||
|
|
||||||
|
activities_queued = 0
|
||||||
|
wellness_days = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if cfg.sync_activities:
|
||||||
|
try:
|
||||||
|
activities_queued = sync_activities(
|
||||||
|
garmin, user_id, cfg.last_sync_at, db, settings.file_store_path
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"activities: {exc}")
|
||||||
|
|
||||||
|
if cfg.sync_wellness:
|
||||||
|
try:
|
||||||
|
wellness_days = sync_wellness(garmin, user_id, cfg.last_sync_at, db)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"wellness: {exc}")
|
||||||
|
|
||||||
|
cfg.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
cfg.last_sync_status = (
|
||||||
|
f"OK — {activities_queued} activities queued, {wellness_days} wellness days synced"
|
||||||
|
if not errors else
|
||||||
|
f"Partial — {'; '.join(errors)}"
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "activities_queued": activities_queued, "wellness_days": wellness_days}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="sync_all_garmin_connect")
|
||||||
|
def sync_all_garmin_connect():
|
||||||
|
"""Hourly beat task: dispatch per-user sync for all enabled configs."""
|
||||||
|
from app.core.database import SyncSessionLocal
|
||||||
|
from app.models.user import GarminConnectConfig
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with SyncSessionLocal() as db:
|
||||||
|
configs = db.execute(
|
||||||
|
select(GarminConnectConfig).where(GarminConnectConfig.sync_enabled == True)
|
||||||
|
).scalars().all()
|
||||||
|
user_ids = [c.user_id for c in configs]
|
||||||
|
|
||||||
|
for uid in user_ids:
|
||||||
|
sync_garmin_connect_user.delay(uid)
|
||||||
|
|
||||||
|
return {"dispatched": len(user_ids)}
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="recalculate_hr_zones_for_user")
|
@celery_app.task(name="recalculate_hr_zones_for_user")
|
||||||
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
|
||||||
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ Pillow==10.3.0
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
python-dateutil==2.9.0
|
python-dateutil==2.9.0
|
||||||
pytz==2024.1
|
pytz==2024.1
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
garminconnect==0.2.24
|
||||||
|
cryptography==42.0.8
|
||||||
@@ -91,6 +91,24 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
beat:
|
||||||
|
image: gitea.yourdomain.com/yourusername/milevault-worker:latest
|
||||||
|
container_name: milevault_beat
|
||||||
|
restart: unless-stopped
|
||||||
|
command: celery -A app.workers.celery_app beat --loglevel=info
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
|
||||||
|
FILE_STORE_PATH: /data/files
|
||||||
|
volumes:
|
||||||
|
- file_data:/data/files
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
|
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
|
||||||
container_name: milevault_frontend
|
container_name: milevault_frontend
|
||||||
|
|||||||
@@ -83,6 +83,26 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.worker
|
||||||
|
container_name: milevault_beat
|
||||||
|
restart: unless-stopped
|
||||||
|
command: celery -A app.workers.celery_app beat --loglevel=info
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
|
FILE_STORE_PATH: /data/files
|
||||||
|
volumes:
|
||||||
|
- file_data:/data/files
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
@@ -110,6 +110,54 @@ export default function ProfilePage() {
|
|||||||
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Garmin Connect sync
|
||||||
|
const { data: garminConfig, refetch: refetchGarmin } = useQuery({
|
||||||
|
queryKey: ['garmin-config'],
|
||||||
|
queryFn: () => api.get('/garmin-sync/config').then(r => r.data),
|
||||||
|
})
|
||||||
|
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true })
|
||||||
|
const [gcSaved, setGcSaved] = useState(false)
|
||||||
|
const [gcError, setGcError] = useState('')
|
||||||
|
const [gcSyncing, setGcSyncing] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (garminConfig?.connected) {
|
||||||
|
setGcForm(f => ({
|
||||||
|
...f,
|
||||||
|
email: garminConfig.email || '',
|
||||||
|
sync_enabled: garminConfig.sync_enabled,
|
||||||
|
sync_activities: garminConfig.sync_activities,
|
||||||
|
sync_wellness: garminConfig.sync_wellness,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [garminConfig])
|
||||||
|
const saveGarmin = useMutation({
|
||||||
|
mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchGarmin()
|
||||||
|
setGcSaved(true)
|
||||||
|
setGcError('')
|
||||||
|
setGcForm(f => ({ ...f, password: '' }))
|
||||||
|
setTimeout(() => setGcSaved(false), 3000)
|
||||||
|
},
|
||||||
|
onError: e => setGcError(e.response?.data?.detail || 'Failed to save'),
|
||||||
|
})
|
||||||
|
const deleteGarmin = useMutation({
|
||||||
|
mutationFn: () => api.delete('/garmin-sync/config'),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchGarmin()
|
||||||
|
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const triggerGarminSync = async () => {
|
||||||
|
setGcSyncing(true)
|
||||||
|
try {
|
||||||
|
await api.post('/garmin-sync/trigger')
|
||||||
|
setTimeout(() => { refetchGarmin(); setGcSyncing(false) }, 3000)
|
||||||
|
} catch (e) {
|
||||||
|
setGcSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PocketID config
|
// PocketID config
|
||||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||||
const [pidSaved, setPidSaved] = useState(false)
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
@@ -248,6 +296,92 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Garmin Connect Sync */}
|
||||||
|
<Section title="⌚ Garmin Connect Sync">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Connect your Garmin account to automatically import new activities and wellness data every hour.
|
||||||
|
Credentials are encrypted at rest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{garminConfig?.connected && (
|
||||||
|
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
|
||||||
|
<span className="text-green-400">✓ Connected as {garminConfig.email}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{garminConfig.last_sync_at && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{garminConfig.last_sync_status && (
|
||||||
|
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
|
||||||
|
{garminConfig.last_sync_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Garmin Connect email">
|
||||||
|
<Input value={gcForm.email} placeholder="you@example.com"
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
|
||||||
|
<Input type="password" value={gcForm.password} placeholder="••••••••"
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 pt-1">
|
||||||
|
{[
|
||||||
|
['sync_enabled', 'Enable hourly sync'],
|
||||||
|
['sync_activities', 'Sync activities (FIT download)'],
|
||||||
|
['sync_wellness', 'Sync wellness data'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={gcForm[key]}
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
|
||||||
|
className="w-4 h-4 accent-blue-500" />
|
||||||
|
<span className="text-sm text-gray-300">{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pt-1">
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => {
|
||||||
|
if (!garminConfig?.connected && !gcForm.password) {
|
||||||
|
setGcError('Password is required for first-time setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = { ...gcForm }
|
||||||
|
if (!payload.password) delete payload.password
|
||||||
|
saveGarmin.mutate(payload)
|
||||||
|
}}
|
||||||
|
loading={saveGarmin.isPending}
|
||||||
|
saved={gcSaved}
|
||||||
|
label={garminConfig?.connected ? 'Update' : 'Connect'}
|
||||||
|
/>
|
||||||
|
{garminConfig?.connected && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={triggerGarminSync}
|
||||||
|
disabled={gcSyncing}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
|
||||||
|
className="text-red-400 hover:text-red-300 text-sm transition-colors">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* PocketID — admin only */}
|
{/* PocketID — admin only */}
|
||||||
{user?.is_admin && (
|
{user?.is_admin && (
|
||||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||||
|
|||||||
Reference in New Issue
Block a user