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.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():
|
||||
@@ -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(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
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")
|
||||
|
||||
@@ -37,6 +37,26 @@ class User(Base):
|
||||
health_metrics = relationship("HealthMetric", 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")
|
||||
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):
|
||||
|
||||
@@ -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,
|
||||
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."""
|
||||
|
||||
@@ -22,4 +22,6 @@ Pillow==10.3.0
|
||||
aiofiles==23.2.1
|
||||
python-dateutil==2.9.0
|
||||
pytz==2024.1
|
||||
psycopg2-binary==2.9.9
|
||||
psycopg2-binary==2.9.9
|
||||
garminconnect==0.2.24
|
||||
cryptography==42.0.8
|
||||
Reference in New Issue
Block a user