Add Garmin Connect auto-sync via python-garminconnect
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

- 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:
2026-06-07 00:08:12 +01:00
parent 0cdc653664
commit 6d224d51c5
9 changed files with 691 additions and 3 deletions
+86 -1
View File
@@ -22,6 +22,12 @@ celery_app.conf.update(
enable_utc=True,
task_track_started=True,
worker_prefetch_multiplier=1,
beat_schedule={
"sync-garmin-connect-hourly": {
"task": "sync_all_garmin_connect",
"schedule": 3600.0, # every hour
},
},
)
WELLNESS_SUFFIXES = (
@@ -46,7 +52,8 @@ def is_wellness_file(file_path: str) -> bool:
@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."""
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,
name=parsed["name"],
sport_type=parsed["sport_type"],
garmin_activity_id=garmin_activity_id,
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
@@ -450,6 +458,83 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
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")
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."""