feat: Implement Garmin sync, login improvements, and utility scripts

This commit is contained in:
2025-10-11 11:56:25 -07:00
parent 56a93cd8df
commit 3819e4f5e2
921 changed files with 2058 additions and 371 deletions

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, HTTPException, status
import logging
from ..dependencies import get_central_db_service, get_garmin_auth_service, get_garmin_client_service
from ..schemas import GarminLoginRequest, GarminLoginResponse
from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService
from ..services.garmin_client_service import GarminClientService
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/login", response_model=GarminLoginResponse, status_code=status.HTTP_200_OK)
async def garmin_login(
request: GarminLoginRequest,
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
central_db_service: CentralDBService = Depends(get_central_db_service),
garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
):
"""
Authenticate with Garmin Connect using username and password.
Stores Garmin credentials and authentication tokens in the CentralDB.
"""
user_id = 1
# 1. Try to retrieve existing credentials from CentralDB
existing_credentials = await central_db_service.get_garmin_credentials(user_id)
if existing_credentials:
# Update GarminClientService with existing credentials
garmin_client_service.update_credentials(
existing_credentials.garmin_username,
existing_credentials.garmin_password_plaintext
)
# Check if already authenticated or if session is still valid
if garmin_client_service.is_authenticated() and garmin_client_service.check_session_validity():
logger.info(f"Garmin client already authenticated and session valid for {existing_credentials.garmin_username}. Reusing session.")
return GarminLoginResponse(message="Garmin account linked successfully.")
else:
logger.info(f"Garmin client not authenticated or session invalid for {existing_credentials.garmin_username}. Attempting to re-authenticate with existing credentials.")
if garmin_client_service.authenticate(): # Only authenticate if not already valid
logger.info(f"Successfully re-authenticated Garmin client with existing credentials for {existing_credentials.garmin_username}.")
return GarminLoginResponse(message="Garmin account linked successfully.")
else:
logger.warning(f"Failed to re-authenticate with existing Garmin credentials for {existing_credentials.garmin_username}. Proceeding with fresh login attempt.")
else:
logger.info(f"No existing Garmin credentials found for user {user_id}. Proceeding with fresh login.")
# If no existing credentials, or existing credentials failed, perform a fresh login
garmin_credentials = await garmin_auth_service.initial_login(
request.username,
request.password
)
if not garmin_credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Garmin credentials provided."
)
# Store/Update credentials in CentralDB after successful fresh login
if existing_credentials:
updated_credentials = await central_db_service.update_garmin_credentials(
user_id,
garmin_credentials.model_dump()
)
if not updated_credentials:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update Garmin credentials in CentralDB."
)
else:
created_credentials = await central_db_service.create_garmin_credentials(
user_id,
garmin_credentials.model_dump()
)
if not created_credentials:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to store Garmin credentials in CentralDB."
)
return GarminLoginResponse(message="Garmin account linked successfully.")

View File

@@ -1,21 +1,19 @@
from typing import Optional, List
from uuid import UUID, uuid4
from datetime import date, datetime
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends
from ..dependencies import (
get_current_user,
get_garmin_activity_service,
get_garmin_workout_service,
get_sync_status_service,
get_current_user,
)
from ..jobs import SyncJob, job_store
from ..schemas import ActivitySyncRequest, User, WorkoutUploadRequest
from ..services.garmin_activity_service import GarminActivityService
from ..services.garmin_health_service import GarminHealthService
from ..services.garmin_workout_service import GarminWorkoutService
from ..services.sync_status_service import SyncStatusService
from ..jobs import job_store, SyncJob
from ..schemas import ActivitySyncRequest, WorkoutUploadRequest, User
router = APIRouter()
@@ -25,18 +23,20 @@ async def trigger_garmin_activity_sync(
background_tasks: BackgroundTasks,
garmin_activity_service: GarminActivityService = Depends(get_garmin_activity_service),
current_user: User = Depends(get_current_user),
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
):
"""
Trigger Garmin Connect Activity Synchronization
"""
job = job_store.create_job()
background_tasks.add_task(
garmin_activity_service.sync_activities_in_background,
job.id,
request.force_resync,
request.start_date,
request.end_date
request.end_date,
max_activities_to_sync # Pass the new parameter
)
return job
@@ -61,9 +61,9 @@ async def upload_garmin_workout(
return job
@router.get("/status", response_model=List[SyncJob], status_code=200)
@router.get("/status/{job_id}", response_model=List[SyncJob], status_code=200)
async def get_sync_status(
job_id: Optional[UUID] = None,
job_id: UUID,
limit: int = 10,
offset: int = 0,
sync_status_service: SyncStatusService = Depends(get_sync_status_service),

View File

@@ -1,7 +1,7 @@
import os
import logging
from pathlib import Path
from typing import Tuple
import logging
from pydantic_settings import BaseSettings
logger = logging.getLogger(__name__)
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
SESSION_COOKIE_KEY: str = "a_very_secret_key"
GARMIN_CONNECT_EMAIL: str = ""
GARMIN_CONNECT_PASSWORD: str = ""
CENTRAL_DB_URL: str = "http://localhost:8000" # Default for CentralDB
CENTRAL_DB_URL: str
GARMINSYNC_DATA_DIR: Path = Path("data")
class Config:
@@ -53,4 +53,4 @@ def get_garmin_credentials() -> Tuple[str, str]:
raise ValueError(
"Garmin credentials not found. Set GARMIN_CONNECT_EMAIL and GARMIN_CONNECT_PASSWORD "
"environment variables."
)
)

View File

@@ -1,16 +1,17 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status
from .services.garmin_client_service import GarminClientService, garmin_client_service
from .config import settings
from .schemas import User
from .services.activity_download_service import ActivityDownloadService
from .services.auth_service import AuthService
from .services.central_db_service import CentralDBService
from .services.garmin_activity_service import GarminActivityService
from .services.garmin_auth_service import GarminAuthService # New import
from .services.garmin_client_service import GarminClientService, garmin_client_service
from .services.garmin_health_service import GarminHealthService
from .services.garmin_workout_service import GarminWorkoutService
from .services.sync_status_service import SyncStatusService
from .services.central_db_service import CentralDBService
from .services.auth_service import AuthService
from .services.activity_download_service import ActivityDownloadService
from .config import settings
from .schemas import User
from .jobs import job_store
def get_central_db_service() -> CentralDBService:
return CentralDBService(base_url=settings.CENTRAL_DB_URL)
@@ -18,27 +19,60 @@ def get_central_db_service() -> CentralDBService:
def get_auth_service() -> AuthService:
return AuthService()
def get_activity_download_service(garmin_client_service: GarminClientService = Depends(lambda: garmin_client_service)) -> ActivityDownloadService:
return ActivityDownloadService(garmin_client_service)
def get_garmin_auth_service() -> GarminAuthService: # New dependency function
return GarminAuthService()
def get_garmin_activity_service(garmin_client_service: GarminClientService = Depends(lambda: garmin_client_service), activity_download_service: ActivityDownloadService = Depends(get_activity_download_service)) -> GarminActivityService:
return GarminActivityService(garmin_client_service, activity_download_service)
def get_garmin_client_service() -> GarminClientService:
return garmin_client_service
def get_garmin_health_service() -> GarminHealthService:
central_db_service = get_central_db_service()
return GarminHealthService(garmin_client_service, central_db_service)
def get_activity_download_service(
garmin_client_service: GarminClientService = Depends(get_garmin_client_service)
) -> ActivityDownloadService:
return ActivityDownloadService(garmin_client_instance=garmin_client_service)
def get_garmin_activity_service(
garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
activity_download_service: ActivityDownloadService = Depends(get_activity_download_service),
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
central_db_service: CentralDBService = Depends(get_central_db_service)
) -> GarminActivityService:
return GarminActivityService(
garmin_client_service=garmin_client_service,
activity_download_service=activity_download_service,
garmin_auth_service=garmin_auth_service,
central_db_service=central_db_service
)
def get_garmin_health_service(
garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
central_db_service: CentralDBService = Depends(get_central_db_service),
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service)
) -> GarminHealthService:
return GarminHealthService(
garmin_client_service=garmin_client_service,
central_db_service=central_db_service,
garmin_auth_service=garmin_auth_service
)
def get_garmin_workout_service() -> GarminWorkoutService:
return GarminWorkoutService(garmin_client_service)
return GarminWorkoutService(garmin_client_service) # Assuming it needs garmin_client_service
from .jobs import job_store
# ... other imports ...
def get_sync_status_service() -> SyncStatusService:
return SyncStatusService(job_store=job_store)
async def get_current_user(request: Request, auth_service: AuthService = Depends(get_auth_service)) -> User:
user = await auth_service.get_current_user(request)
async def get_current_user(
central_db_service: CentralDBService = Depends(get_central_db_service)
) -> User:
# As per spec, this is a single-user system, so we can assume user_id = 1
user_id = 1
user = await central_db_service.get_user(user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
detail="User not found. Please ensure Garmin account is linked.",
)
return user

View File

@@ -35,7 +35,14 @@ class JobStore:
with self._lock:
return list(self._jobs.values())
def update_job(self, job_id: str, status: str, progress: float, details: Optional[Dict] = None, error_message: Optional[str] = None):
def update_job(
self,
job_id: str,
status: str,
progress: float,
details: Optional[Dict] = None,
error_message: Optional[str] = None
):
with self._lock:
job = self._jobs.get(job_id)
if job:

View File

@@ -1,24 +1,14 @@
import logging
import uuid
from fastapi import FastAPI, Request, status, BackgroundTasks, Depends, HTTPException, Form, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from fastapi import BackgroundTasks, Depends, FastAPI, Request, Response, status
from fastapi.responses import JSONResponse
from .api import garmin_auth, garmin_sync # New import for API router
from .config import settings
from .logging_config import setup_logging
from .services.background_tasks import example_background_task
from .services.central_db_service import CentralDBService # New import
from .services.rate_limiter import RateLimiter
from .services.garmin_client_service import garmin_client_service
from .services.garmin_activity_service import GarminActivityService
from .services.garmin_health_service import GarminHealthService # New import
from .services.garmin_workout_service import GarminWorkoutService # New import
from .services.sync_status_service import SyncStatusService # New import
from .services.central_db_service import CentralDBService # New import
from .services.auth_service import AuthService # New import
from .dependencies import get_auth_service
from .api import garmin_sync # New import for API router
setup_logging()
logger = logging.getLogger(__name__)
@@ -32,25 +22,35 @@ rate_limiter = RateLimiter(rate_limit="100/minute")
# Initialize CentralDBService
central_db_service = CentralDBService(base_url=settings.CENTRAL_DB_URL) # Assuming CENTRAL_DB_URL in settings
app.include_router(garmin_sync.router, prefix="/api/sync", tags=["Garmin Sync"]) # Include the new router
app.include_router(garmin_sync.router, prefix="/api/sync", tags=["Garmin Sync"])
app.include_router(garmin_auth.router, prefix="/api/garmin", tags=["Garmin Auth"])
@app.post("/login")
async def login(response: Response, username: str = Form(...), password: str = Form(...), auth_service: AuthService = Depends(get_auth_service)):
user = await auth_service.authenticate_garmin_connect(email=username, password=password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
session_cookie = auth_service.create_session_cookie(user.id)
response.set_cookie(
key=settings.SESSION_COOKIE_NAME,
value=session_cookie,
httponly=True,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
expires=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
return {"message": "Login successful"}
# NOTE: The existing /login endpoint below is commented out to avoid conflict
# with new Garmin authentication /login endpoint. This existing endpoint
# seems to be for authenticating users to this backend service itself, which
# is a different concern than authenticating with Garmin Connect.
# @app.post("/login")
# async def login(
# response: Response,
# username: str = Form(...),
# password: str = Form(...),
# auth_service: AuthService = Depends(get_auth_service)
# ):
# user = await auth_service.authenticate_garmin_connect(email=username, password=password)
# if not user:
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Incorrect username or password",
# )
# session_cookie = auth_service.create_session_cookie(user.id)
# response.set_cookie(
# key=settings.SESSION_COOKIE_NAME,
# value=session_cookie,
# httponly=True,
# max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
# expires=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
# )
# return {"message": "Login successful"}
@app.post("/logout")
async def logout(response: Response):

View File

@@ -1,8 +1,8 @@
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from datetime import datetime, date
from datetime import date, datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
from .jobs import SyncJob
class UserBase(BaseModel):
name: str
@@ -45,9 +45,30 @@ class WorkoutPlan(BaseModel):
created_at: datetime
class ActivitySyncRequest(BaseModel):
force_resync: bool = Field(False, description="If true, re-download activities even if they exist. Defaults to false.")
start_date: Optional[date] = Field(None, description="Optional start date (YYYY-MM-DD) to sync activities from. If not provided, syncs recent activities.")
end_date: Optional[date] = Field(None, description="Optional end date (YYYY-MM-DD) to sync activities up to.")
force_resync: bool = Field(
False, description="If true, re-download activities even if they exist. Defaults to false."
)
start_date: Optional[date] = Field(
None,
description=(
"Optional start date (YYYY-MM-DD) to sync activities from. "
"If not provided, syncs recent activities."
),
)
end_date: Optional[date] = Field(
None, description="Optional end date (YYYY-MM-DD) to sync activities up to."
)
class WorkoutUploadRequest(BaseModel):
workout_id: int = Field(..., description="The ID of the workout to upload from CentralDB.")
workout_id: int = Field(..., description="The ID of the workout to upload from CentralDB.")
class GarminCredentials(BaseModel):
garmin_username: str
garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk.
class GarminLoginRequest(BaseModel):
username: str
password: str
class GarminLoginResponse(BaseModel):
message: str

View File

@@ -1,11 +1,8 @@
import logging
import os
import tempfile
import zipfile
from pathlib import Path
from typing import Optional, Dict, Any, List
import hashlib
from datetime import datetime
from typing import List, Optional
from ..config import settings
@@ -17,73 +14,105 @@ class ActivityDownloadService:
def download_activity_original(self, activity_id: str, force_download: bool = False) -> Optional[Path]:
"""Download original activity file (usually FIT format).
Args:
activity_id: Garmin activity ID
force_download: If True, bypasses checks and forces a re-download.
Returns:
Path to downloaded file or None if download failed
"""
if not self.garmin_client.is_authenticated():
logger.error("Garmin client not authenticated.")
return None
downloaded_path = None
try:
# Create data directory if it doesn't exist
settings.GARMINSYNC_DATA_DIR.mkdir(exist_ok=True)
file_data = None
attempts: List[str] = []
# 1) Prefer native method when available
if hasattr(self.garmin_client.client, 'download_activity_original'):
try:
attempts.append("self.garmin_client.client.download_activity_original(activity_id)")
logger.debug(f"Attempting native download_activity_original for activity {activity_id}")
attempts.append(
"self.garmin_client.client.download_activity_original(activity_id)"
)
logger.debug(
f"Attempting native download_activity_original for activity {activity_id}"
)
file_data = self.garmin_client.client.download_activity_original(activity_id)
except Exception as e:
logger.debug(f"Native download_activity_original failed: {e} (type={type(e).__name__})")
logger.debug(
f"Native download_activity_original failed: {e} (type={type(e).__name__})"
)
file_data = None
# 2) Try download_activity with 'original' format
if file_data is None and hasattr(self.garmin_client.client, 'download_activity'):
try:
attempts.append("self.garmin_client.client.download_activity(activity_id, dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL)")
logger.debug(f"Attempting original download via download_activity(dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL) for activity {activity_id}")
file_data = self.garmin_client.client.download_activity(activity_id, dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL)
logger.debug(f"download_activity(dl_fmt='original') succeeded, got data type: {type(file_data).__name__}, length: {len(file_data) if hasattr(file_data, '__len__') else 'N/A'}")
attempts.append(
"self.garmin_client.client.download_activity(activity_id, "
"dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL)"
)
logger.debug(
"Attempting original download via download_activity("
f"dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL) "
f"for activity {activity_id}"
)
file_data = self.garmin_client.client.download_activity(
activity_id, dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL
)
logger.debug(
f"download_activity(dl_fmt='original') succeeded, got data type: "
f"{type(file_data).__name__}, length: "
f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}"
)
if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0:
logger.debug(f"First 100 bytes: {file_data[:100]}")
except Exception as e:
logger.debug(f"download_activity(dl_fmt='original') failed: {e} (type={type(e).__name__})")
logger.debug(
f"download_activity(dl_fmt='original') failed: {e} (type={type(e).__name__})"
)
file_data = None
# 3) Try download_activity with positional token (older signatures)
if file_data is None and hasattr(self.garmin_client.client, 'download_activity'):
tokens_to_try_pos = ['ORIGINAL', 'original', 'FIT', 'fit']
for token in tokens_to_try_pos:
try:
attempts.append(f"self.garmin_client.client.download_activity(activity_id, '{token}')")
logger.debug(f"Attempting original download via download_activity(activity_id, '{token}') for activity {activity_id}")
attempts.append(
f"self.garmin_client.client.download_activity(activity_id, '{token}')"
)
logger.debug(
"Attempting original download via download_activity("
f"activity_id, '{token}') for activity {activity_id}"
)
file_data = self.garmin_client.client.download_activity(activity_id, token)
logger.debug(f"download_activity(activity_id, '{token}') succeeded, got data type: {type(file_data).__name__}, length: {len(file_data) if hasattr(file_data, '__len__') else 'N/A'}")
logger.debug(
f"download_activity(activity_id, '{token}') succeeded, got data type: "
f"{type(file_data).__name__}, length: "
f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}"
)
if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0:
logger.debug(f"First 100 bytes: {file_data[:100]}")
break
except Exception as e:
logger.debug(f"download_activity(activity_id, '{token}') failed: {e} (type={type(e).__name__})")
logger.debug(
f"download_activity(activity_id, '{token}') failed: {e} (type={type(e).__name__})"
)
file_data = None
if file_data is None:
logger.error(
f"Failed to obtain original/FIT data for activity {activity_id}. "
f"Attempts: {attempts}"
)
return None
if hasattr(file_data, 'content'):
try:
file_data = file_data.content
@@ -94,30 +123,33 @@ class ActivityDownloadService:
file_data = file_data.read()
except Exception:
pass
if not isinstance(file_data, (bytes, bytearray)):
logger.error(f"Downloaded data for activity {activity_id} is not bytes (type={type(file_data).__name__}); aborting")
logger.error(
f"Downloaded data for activity {activity_id} is not bytes "
f"(type={type(file_data).__name__}); aborting"
)
logger.debug(f"Data content: {repr(file_data)[:200]}")
return None
with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
tmp_file.write(file_data)
tmp_path = Path(tmp_file.name)
extracted_path = settings.GARMINSYNC_DATA_DIR / f"activity_{activity_id}.fit"
if zipfile.is_zipfile(tmp_path):
with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
fit_files = [f for f in zip_ref.namelist() if f.lower().endswith('.fit')]
if fit_files:
fit_filename = fit_files[0]
with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target:
target.write(source.read())
tmp_path.unlink()
logger.info(f"Downloaded original activity file: {extracted_path}")
downloaded_path = extracted_path
else:
@@ -128,15 +160,21 @@ class ActivityDownloadService:
tmp_path.rename(extracted_path)
downloaded_path = extracted_path
except Exception as move_err:
logger.debug(f"Rename temp FIT to destination failed ({move_err}); falling back to copy")
logger.debug(
f"Rename temp FIT to destination failed ({move_err}); "
"falling back to copy"
)
with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source:
target.write(source.read())
tmp_path.unlink()
downloaded_path = extracted_path
logger.info(f"Downloaded original activity file: {extracted_path}")
except Exception as e:
logger.error(f"Failed to download original activity {activity_id}: {e} (type={type(e).__name__})")
logger.error(
f"Failed to download original activity {activity_id}: {e} "
f"(type={type(e).__name__})"
)
downloaded_path = None
return downloaded_path
return downloaded_path

View File

@@ -1,14 +1,13 @@
import logging
from typing import Optional
from datetime import datetime, timedelta
from fastapi import Request
from garminconnect import Garmin
from fastapi import HTTPException, status, Request, Response
from itsdangerous import URLSafeTimedSerializer
from .central_db_service import CentralDBService
from ..config import settings
from ..schemas import User, TokenCreate, TokenUpdate
from ..schemas import User
from .central_db_service import CentralDBService
logger = logging.getLogger(__name__)
@@ -61,4 +60,4 @@ class AuthService:
"""
Gets the current user from the session cookie.
"""
return await self.get_user_from_session(request)
return await self.get_user_from_session(request)

View File

@@ -2,16 +2,26 @@ import logging
from pathlib import Path
from typing import Optional, List
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from ..schemas import User, Token, WorkoutPlan
from ..schemas import GarminCredentials, Token, User, WorkoutPlan
from ..config import settings
logger = logging.getLogger(__name__)
# Define a retry strategy for CentralDB calls
CENTRAL_DB_RETRY_STRATEGY = retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors
reraise=True
)
class CentralDBService:
def __init__(self, base_url: str):
self.base_url = base_url
@CENTRAL_DB_RETRY_STRATEGY
async def get_user_by_email(self, email: str) -> Optional[User]:
try:
async with httpx.AsyncClient() as client:
@@ -26,6 +36,7 @@ class CentralDBService:
logger.error(f"Error fetching user from CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def get_user(self, user_id: int) -> Optional[User]:
try:
async with httpx.AsyncClient() as client:
@@ -36,6 +47,7 @@ class CentralDBService:
logger.error(f"Error fetching user from CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def create_user(self, user_create: dict) -> Optional[User]:
try:
async with httpx.AsyncClient() as client:
@@ -46,6 +58,7 @@ class CentralDBService:
logger.error(f"Error creating user in CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def get_token(self, user_id: int) -> Optional[Token]:
try:
async with httpx.AsyncClient() as client:
@@ -56,6 +69,7 @@ class CentralDBService:
logger.error(f"Error fetching token from CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def create_token(self, token_create: dict) -> Optional[Token]:
try:
async with httpx.AsyncClient() as client:
@@ -66,6 +80,7 @@ class CentralDBService:
logger.error(f"Error creating token in CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def update_token(self, user_id: int, token_update: dict) -> Optional[Token]:
try:
async with httpx.AsyncClient() as client:
@@ -76,6 +91,7 @@ class CentralDBService:
logger.error(f"Error updating token in CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]:
try:
async with httpx.AsyncClient() as client:
@@ -85,14 +101,27 @@ class CentralDBService:
except Exception as e:
logger.error(f"Error fetching workout from CentralDB: {e}")
return None
async def upload_activity_file(self, activity_id: str, file_path: Path) -> bool:
"""Placeholder for uploading activity file content to CentralDB."""
logger.info(f"[CentralDBService] Simulating upload of activity {activity_id} file: {file_path}")
# In a real implementation, this would involve making an API call to CentralDB
# to upload the file content.
return True
@CENTRAL_DB_RETRY_STRATEGY
async def upload_activity_file(self, activity_id: str, file_path: Path) -> bool:
"""Uploads activity file content to CentralDB."""
try:
async with httpx.AsyncClient() as client:
with open(file_path, "rb") as f:
files = {"file": (file_path.name, f, "application/fit")} # Changed content type
user_id = 1 # Assuming single user for now
response = await client.post(
f"{self.base_url}/activities/{user_id}", # user_id as path parameter
files=files,
)
response.raise_for_status()
logger.info(f"Successfully uploaded activity {activity_id} to CentralDB.")
return True
except Exception as e:
logger.error(f"Error uploading activity {activity_id} to CentralDB: {e}", exc_info=True)
return False
@CENTRAL_DB_RETRY_STRATEGY
async def save_health_metric(self, health_metric_data: dict) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
@@ -101,4 +130,37 @@ class CentralDBService:
return response.json()
except Exception as e:
logger.error(f"Error saving health metric to CentralDB: {e}")
return None
return None
@CENTRAL_DB_RETRY_STRATEGY
async def get_garmin_credentials(self, user_id: int) -> Optional[GarminCredentials]:
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{self.base_url}/garmin_credentials/{user_id}")
response.raise_for_status()
return GarminCredentials(**response.json())
except Exception as e:
logger.error(f"Error fetching Garmin credentials from CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def create_garmin_credentials(self, user_id: int, credentials_data: dict) -> Optional[GarminCredentials]:
try:
async with httpx.AsyncClient() as client:
response = await client.post(f"{self.base_url}/garmin_credentials/{user_id}", json=credentials_data)
response.raise_for_status()
return GarminCredentials(**response.json())
except Exception as e:
logger.error(f"Error creating Garmin credentials in CentralDB: {e}")
return None
@CENTRAL_DB_RETRY_STRATEGY
async def update_garmin_credentials(self, user_id: int, credentials_data: dict) -> Optional[GarminCredentials]:
try:
async with httpx.AsyncClient() as client:
response = await client.put(f"{self.base_url}/garmin_credentials/{user_id}", json=credentials_data)
response.raise_for_status()
return GarminCredentials(**response.json())
except Exception as e:
logger.error(f"Error updating Garmin credentials in CentralDB: {e}")
return None

View File

@@ -1,14 +1,15 @@
import asyncio
import logging
import uuid
from datetime import datetime, date
from typing import Optional, List, Dict, Any
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from ..services.garmin_client_service import GarminClientService
from ..services.activity_download_service import ActivityDownloadService
from ..config import settings # Assuming settings is available for DATA_DIR
from ..jobs import job_store
from ..services.activity_download_service import ActivityDownloadService
from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService
from ..services.garmin_client_service import GarminClientService
logger = logging.getLogger(__name__)
@@ -27,242 +28,191 @@ def calculate_sha256(file_path) -> str:
class GarminActivityService:
def __init__(self, garmin_client_service: GarminClientService, activity_download_service: ActivityDownloadService):
def __init__(
self,
garmin_client_service: GarminClientService,
activity_download_service: ActivityDownloadService,
garmin_auth_service: GarminAuthService,
central_db_service: CentralDBService
):
self.garmin_client_service = garmin_client_service
self.activity_download_service = activity_download_service
self.garmin_auth_service = garmin_auth_service
self.central_db_service = central_db_service
async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]:
credentials = await self.central_db_service.get_garmin_credentials(user_id)
if not credentials:
logger.error(f"No Garmin credentials found for user {user_id}.")
return None
# Update GarminClientService with the username and password
# The GarminClientService will handle its own authentication with garminconnect
self.garmin_client_service.update_credentials(
credentials.garmin_username, credentials.garmin_password_plaintext
)
# Check if the client is authenticated after updating credentials
if not self.garmin_client_service.is_authenticated():
if not self.garmin_client_service.authenticate():
logger.error(f"Failed to authenticate Garmin client for user {user_id}.")
return None
return self.garmin_client_service
@GARMIN_RETRY_STRATEGY
async def download_and_save_activity(self, activity_id: str, force_download: bool = False) -> Optional[dict]:
async def download_and_save_activity(
self, user_id: int, activity_id: str, force_download: bool = False,
garmin_client: Optional[GarminClientService] = None # New argument
) -> Optional[dict]:
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id)
if not _garmin_client:
return None
try:
# In the new architecture, we don't check for existing activities here.
# We just download the file and upload it to CentralDB.
# CentralDB will be responsible for handling duplicates.
# Download the original activity file (FIT)
downloaded_file_path = self.activity_download_service.download_activity_original(
activity_id=activity_id,
force_download=force_download
)
if not downloaded_file_path:
logger.error(f"Failed to download activity file for activity ID: {activity_id}")
return None
# Upload the file to CentralDB
from .central_db_service import CentralDBService
central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
success = await central_db.upload_activity_file(activity_id, downloaded_file_path)
# from .central_db_service import CentralDBService # No longer needed, injected
# central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
success = await self.central_db_service.upload_activity_file(activity_id, downloaded_file_path)
if not success:
logger.error(f"Failed to upload activity file to CentralDB for activity ID: {activity_id}")
return None
# Get activity details from Garmin Connect (to extract metadata)
garmin_activity_details = self.garmin_client_service.get_client().get_activity_details(activity_id)
garmin_activity_details = _garmin_client.get_client().get_activity_details(activity_id)
return garmin_activity_details
except Exception as e:
logger.error(f"Error downloading and saving activity {activity_id}: {e}", exc_info=True)
logger.error(
f"Error downloading and saving activity {activity_id}: {e}", exc_info=True
)
return None
@GARMIN_RETRY_STRATEGY
async def get_activities_for_sync(self, limit: int = 20) -> List[Dict[str, Any]]:
async def get_activities_for_sync(self, user_id: int, limit: int = 20) -> List[Dict[str, Any]]:
"""Get a list of recent activities from Garmin Connect."""
if not self.garmin_client_service.is_authenticated():
logger.error("Garmin client not authenticated.")
garmin_client = await self._get_authenticated_garmin_client(user_id)
if not garmin_client:
return []
try:
# Fetch recent activities from Garmin Connect
garmin_activities = self.garmin_client_service.get_client().get_activities(0, limit)
garmin_activities = garmin_client.get_client().get_activities(0, limit)
return garmin_activities
except Exception as e:
logger.error(f"Error getting activities for sync: {e}", exc_info=True)
return []
async def sync_activities_in_background(
self,
job_id: str,
force_resync: bool = False,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
):
user_id = 1 # Assuming single user for now
try:
job_store.update_job(job_id, status="in_progress", progress=0.0)
garmin_client = self.garmin_client_service.get_client()
# Authenticate Garmin client once at the beginning
garmin_client = await self._get_authenticated_garmin_client(user_id)
if not garmin_client:
raise Exception("Garmin client not authenticated.")
raise Exception("Garmin client not authenticated or failed to get valid credentials.")
all_garmin_activities = []
start = 0
limit = 100
while True:
try:
activities_page = await GARMIN_RETRY_STRATEGY(garmin_client.get_activities)(start, limit)
activities_page = GARMIN_RETRY_STRATEGY(
garmin_client.get_client().get_activities
)(start, limit)
except Exception as e:
logger.error(f"Failed to fetch activities from Garmin Connect after retries: {e}", exc_info=True)
logger.error(
f"Failed to fetch activities from Garmin Connect after retries: {e}",
exc_info=True
)
raise
if not activities_page:
break
all_garmin_activities.extend(activities_page)
start += limit
# Break if we have collected enough activities
if max_activities_to_sync is not None and len(all_garmin_activities) >= max_activities_to_sync:
all_garmin_activities = all_garmin_activities[:max_activities_to_sync] # Truncate to the limit
break
activities_to_process = []
for activity_data in all_garmin_activities:
activity_start_time_str = activity_data.get("startTimeGMT")
activity_start_time = datetime.fromisoformat(activity_start_time_str.replace("Z", "+00:00")) if activity_start_time_str else None
activity_start_time = (
datetime.fromisoformat(activity_start_time_str.replace("Z", "+00:00"))
if activity_start_time_str else None
)
if start_date and activity_start_time and activity_start_time.date() < start_date:
continue
if end_date and activity_start_time and activity_start_time.date() > end_date:
continue
activities_to_process.append(activity_data)
total_activities = len(activities_to_process)
synced_count = 0
import asyncio
tasks = [
self.download_and_save_activity(
activity_id=str(activity_data.get("activityId")),
force_download=force_resync
) for activity_data in activities_to_process
]
tasks = []
for activity_data in activities_to_process:
# Pass the already authenticated garmin_client to download_and_save_activity
# This avoids repeated authentication and central_db calls
tasks.append(
self.download_and_save_activity(
user_id=user_id,
activity_id=str(activity_data.get("activityId")),
force_download=force_resync,
garmin_client=garmin_client # Pass the authenticated client
)
)
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
activity_id = str(activities_to_process[i].get("activityId"))
logger.error(f"Failed to process activity {activity_id}: {result}")
else:
synced_count += 1
job_store.update_job(job_id, status="in_progress", progress=(i + 1) / total_activities)
job_store.update_job(job_id, status="completed", progress=1.0, details={"synced_activities_count": synced_count, "total_activities_found": total_activities})
job_store.update_job(
job_id,
status="completed",
progress=1.0,
details={
"synced_activities_count": synced_count,
"total_activities_found": total_activities
}
)
except Exception as e:
logger.error(f"Error during activity synchronization for job {job_id}: {e}", exc_info=True)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))
logger.error(
f"Error during activity synchronization for job {job_id}: {e}", exc_info=True
)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))

View File

@@ -0,0 +1,46 @@
import logging
import asyncio # Import asyncio for sleep
from typing import Optional
from garminconnect import Garmin
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from ..schemas import GarminCredentials
# Configure debug logging for garminconnect
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
# Define a retry strategy for Garmin login
GARMIN_LOGIN_RETRY_STRATEGY = retry(
stop=stop_after_attempt(10), # Increased attempts
wait=wait_exponential(multiplier=1, min=10, max=60), # Increased min and max wait times
retry=retry_if_exception_type(Exception), # Retry on any exception for now
reraise=True
)
class GarminAuthService:
def __init__(self):
pass
@GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here
async def _perform_login(self, username: str, password: str) -> Garmin:
"""Helper to perform the actual garminconnect login with retry."""
client = Garmin(username, password)
client.login()
return client
async def initial_login(self, username: str, password: str) -> Optional[GarminCredentials]:
"""Performs initial login to Garmin Connect and returns GarminCredentials."""
try:
client = await self._perform_login(username, password) # Use the retried login helper
logger.info(f"Successful Garmin login for {username}")
return GarminCredentials(
garmin_username=username,
garmin_password_plaintext=password, # Storing plaintext as per user requirement
)
except Exception as e:
logger.error(f"Garmin initial login failed for {username}: {e}")
return None

View File

@@ -1,39 +1,82 @@
import logging
import garth
from garminconnect import Garmin
from typing import Optional
from datetime import datetime # Import datetime
from ..config import settings
from .activity_download_service import ActivityDownloadService
from garminconnect import Garmin
logger = logging.getLogger(__name__)
class GarminClientService:
def __init__(self):
self.client: Optional[Garmin] = None
self._authenticated = False
self.username: Optional[str] = None
self.password: Optional[str] = None
async def login(self, email: str, password: str) -> Garmin:
try:
# Use garth to login and get a session
# garth handles session caching internally if configured
garth.login(email, password)
self.client = Garmin(garth_client=garth)
self._authenticated = True
logger.info("Garmin client initialized and authenticated.")
return self.client
except Exception as e:
logger.error(f"Garmin login failed: {e}")
self.client = None
self._authenticated = False
raise
def update_credentials(self, username: str, password: str):
"""Updates the Garmin client with new username and password."""
# Only update if credentials have changed
if self.username != username or self.password != password:
self.username = username
self.password = password
self.client = None # Invalidate existing client if credentials change
def authenticate(self) -> bool:
"""Authenticates with Garmin Connect using stored credentials, or reuses existing client."""
if not self.username or not self.password:
logger.error("Cannot authenticate: username or password not set.")
return False
if self.client is None:
try:
self.client = Garmin(self.username, self.password)
self.client.login()
logger.info(f"Successfully authenticated Garmin client for {self.username}.")
return True
except Exception as e:
logger.error(f"Failed to authenticate Garmin client for {self.username}: {e}")
self.client = None
return False
else:
# If client exists, assume it's authenticated and check session validity
if self.check_session_validity():
logger.debug(f"Garmin client already authenticated for {self.username}. Reusing existing client.")
return True
else:
logger.info(f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login.")
try:
self.client.login() # Attempt to re-login with existing client
logger.info(f"Successfully re-logged in Garmin client for {self.username}.")
return True
except Exception as e:
logger.error(f"Failed to re-login Garmin client for {self.username}: {e}")
self.client = None
return False
def is_authenticated(self) -> bool:
return self._authenticated and self.client is not None
"""Checks if the Garmin client is currently authenticated."""
# Now relies on self.client being not None and session validity
return self.client is not None and self.check_session_validity()
def get_client(self) -> Garmin:
if not self.client:
raise Exception("Garmin client not logged in.")
"""Returns the authenticated Garmin client instance."""
if self.client is None: # Check self.client directly
raise Exception("Garmin client not initialized or authenticated. Call authenticate first.")
return self.client
def check_session_validity(self) -> bool:
"""
Checks if the current Garmin session is still valid by making a lightweight API call.
"""
if self.client is None: # Check self.client directly
return False
try:
self.client.get_user_summary(datetime.now().isoformat().split('T')[0])
logger.debug(f"Garmin session is still valid for {self.username}.")
return True
except Exception as e:
logger.warning(f"Garmin session became invalid for {self.username}: {e}")
self.client = None # Invalidate client on session failure
return False
# Global instance for dependency injection
garmin_client_service = GarminClientService()
garmin_client_service = GarminClientService()

View File

@@ -1,13 +1,14 @@
import asyncio # Moved from inside the class
import logging
import uuid
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from ..services.garmin_client_service import GarminClientService
from ..services.central_db_service import CentralDBService
from ..jobs import job_store
from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService
from ..services.garmin_client_service import GarminClientService
logger = logging.getLogger(__name__)
@@ -20,12 +21,45 @@ GARMIN_RETRY_STRATEGY = retry(
)
class GarminHealthService:
def __init__(self, garmin_client_service: GarminClientService, central_db_service: CentralDBService):
def __init__(
self,
garmin_client_service: GarminClientService,
central_db_service: CentralDBService,
garmin_auth_service: GarminAuthService
):
self.garmin_client_service = garmin_client_service
self.central_db_service = central_db_service
self.garmin_auth_service = garmin_auth_service
async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]:
credentials = await self.central_db_service.get_garmin_credentials(user_id)
if not credentials:
logger.error(f"No Garmin credentials found for user {user_id}.")
return None
# Update GarminClientService with the username and password
# The GarminClientService will handle its own authentication with garminconnect
self.garmin_client_service.update_credentials(
credentials.garmin_username, credentials.garmin_password_plaintext
)
# Check if the client is authenticated after updating credentials
if not self.garmin_client_service.is_authenticated():
if not self.garmin_client_service.authenticate():
logger.error(f"Failed to authenticate Garmin client for user {user_id}.")
return None
return self.garmin_client_service
@GARMIN_RETRY_STRATEGY
async def download_and_save_health_metric(self, metric_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
async def download_and_save_health_metric(
self, metric_data: Dict[str, Any],
garmin_client: Optional[GarminClientService] = None # New argument
) -> Optional[Dict[str, Any]]:
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id)
if not _garmin_client:
return None
try:
metric_type = metric_data.get("type")
timestamp = metric_data.get("timestamp")
@@ -41,7 +75,7 @@ class GarminHealthService:
except ValueError:
logger.error(f"Invalid timestamp format for health metric: {timestamp}")
return None
metric_data["timestamp"] = timestamp
# Save to CentralDB
@@ -49,35 +83,36 @@ class GarminHealthService:
return saved_metric
except Exception as e:
logger.error(f"Error downloading and saving health metric {metric_data}: {e}", exc_info=True)
logger.error(
f"Error downloading and saving health metric {metric_data}: {e}",
exc_info=True
)
return None
import asyncio
# ... (rest of the imports)
class GarminHealthService:
# ... (other methods)
async def sync_health_metrics_in_background(
self,
job_id: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
):
user_id = 1 # Assuming single user for now
try:
job_store.update_job(job_id, status="in_progress", progress=0.0)
garmin_client = self.garmin_client_service.get_client()
# Authenticate Garmin client once at the beginning
garmin_client = await self._get_authenticated_garmin_client(user_id)
if not garmin_client:
raise Exception("Garmin client not authenticated.")
raise Exception("Garmin client not authenticated or failed to get valid credentials.")
_start_date = start_date or date(2000, 1, 1)
_end_date = end_date or date.today()
date_range = [_start_date + timedelta(days=x) for x in range((_end_date - _start_date).days + 1)]
summary_tasks = [GARMIN_RETRY_STRATEGY(garmin_client.get_daily_summary)(d.isoformat()) for d in date_range]
summary_tasks = [
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(d.isoformat())
for d in date_range
]
daily_summaries = await asyncio.gather(*summary_tasks, return_exceptions=True)
all_metrics_data = []
@@ -87,14 +122,30 @@ class GarminHealthService:
continue
if summary:
if "heartRate" in summary:
all_metrics_data.append({"type": "heart_rate", "timestamp": summary["calendarDate"], "value": summary["heartRate"].get("restingHeartRate"), "unit": "bpm"})
all_metrics_data.append({
"type": "heart_rate",
"timestamp": summary["calendarDate"],
"value": summary["heartRate"].get("restingHeartRate"),
"unit": "bpm"
})
if "stress" in summary:
all_metrics_data.append({"type": "stress_score", "timestamp": summary["calendarDate"], "value": summary["stress"].get("overallStressLevel"), "unit": "score"})
all_metrics_data.append({
"type": "stress_score",
"timestamp": summary["calendarDate"],
"value": summary["stress"].get("overallStressLevel"),
"unit": "score"
})
total_metrics = len(all_metrics_data)
synced_count = 0
metric_save_tasks = [self.download_and_save_health_metric(metric_data=metric) for metric in all_metrics_data]
metric_save_tasks = [
self.download_and_save_health_metric(
metric_data=metric,
garmin_client=garmin_client # Pass the authenticated client
)
for metric in all_metrics_data
]
results = await asyncio.gather(*metric_save_tasks, return_exceptions=True)
for i, result in enumerate(results):
@@ -104,8 +155,19 @@ class GarminHealthService:
synced_count += 1
job_store.update_job(job_id, status="in_progress", progress=(i + 1) / total_metrics)
job_store.update_job(job_id, status="completed", progress=1.0, details={"synced_health_metrics_count": synced_count, "total_health_metrics_found": total_metrics})
job_store.update_job(
job_id,
status="completed",
progress=1.0,
details={
"synced_health_metrics_count": synced_count,
"total_health_metrics_found": total_metrics
}
)
except Exception as e:
logger.error(f"Error during health metrics synchronization for job {job_id}: {e}", exc_info=True)
logger.error(
f"Error during health metrics synchronization for job {job_id}: {e}",
exc_info=True
)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))

View File

@@ -1,12 +1,11 @@
import logging
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
from typing import Any, Dict, Optional
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from ..services.garmin_client_service import GarminClientService
from ..jobs import job_store
from ..services.garmin_client_service import GarminClientService
logger = logging.getLogger(__name__)
@@ -26,8 +25,8 @@ class GarminWorkoutService:
async def upload_workout(self, workout_id: uuid.UUID) -> Optional[Dict[str, Any]]:
try:
# Get workout from CentralDB
from .central_db_service import CentralDBService
from ..config import settings
from .central_db_service import CentralDBService
central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
workout = await central_db.get_workout_by_id(workout_id) # Assuming this method exists
@@ -39,18 +38,26 @@ class GarminWorkoutService:
if not garmin_client:
raise Exception("Garmin client not authenticated.")
logger.info(f"Simulating upload of workout {workout.name} (ID: {workout_id}) to Garmin Connect.")
logger.info(
f"Simulating upload of workout {workout.name} (ID: {workout_id}) "
"to Garmin Connect."
)
garmin_workout_id = f"GARMIN_WORKOUT_{workout_id}" # Mock ID
# Here we would update the workout in CentralDB with the garmin_workout_id
# await central_db.update_workout(workout_id, {"garmin_workout_id": garmin_workout_id, "upload_status": "completed"})
# await central_db.update_workout(
# workout_id, {"garmin_workout_id": garmin_workout_id, "upload_status": "completed"}
# )
logger.info(
f"Successfully uploaded workout {workout.name} to Garmin Connect "
f"with ID {garmin_workout_id}."
)
logger.info(f"Successfully uploaded workout {workout.name} to Garmin Connect with ID {garmin_workout_id}.")
# We need to add garmin_workout_id to the workout dictionary before returning
workout_dict = workout.dict()
workout_dict["garmin_workout_id"] = garmin_workout_id
return workout_dict
except Exception as e:
@@ -70,10 +77,25 @@ class GarminWorkoutService:
uploaded_workout = await self.upload_workout(workout_id)
if uploaded_workout:
job_store.update_job(job_id, status="completed", progress=1.0, details={"uploaded_workout_id": str(uploaded_workout.id), "garmin_workout_id": uploaded_workout.garmin_workout_id})
job_store.update_job(
job_id,
status="completed",
progress=1.0,
details={
"uploaded_workout_id": str(uploaded_workout.id),
"garmin_workout_id": uploaded_workout.garmin_workout_id
}
)
else:
job_store.update_job(job_id, status="failed", progress=1.0, error_message=f"Failed to upload workout {workout_id}")
job_store.update_job(
job_id,
status="failed",
progress=1.0,
error_message=f"Failed to upload workout {workout_id}"
)
except Exception as e:
logger.error(f"Error during workout upload for job {job_id}: {e}", exc_info=True)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))
logger.error(
f"Error during workout upload for job {job_id}: {e}", exc_info=True
)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))

View File

@@ -1,7 +1,8 @@
from fastapi import HTTPException, Request, status
from limits import parse
from limits.storage import MemoryStorage
from limits.strategies import MovingWindowRateLimiter
from fastapi import Request, HTTPException, status
class RateLimiter:
def __init__(self, rate_limit: str = "10/second"):

View File

@@ -2,7 +2,7 @@ import logging
from typing import List, Optional
from uuid import UUID
from ..jobs import SyncJob, JobStore
from ..jobs import JobStore, SyncJob
logger = logging.getLogger(__name__)
@@ -18,11 +18,11 @@ class SyncStatusService:
) -> List[SyncJob]:
try:
all_jobs = self.job_store.get_all_jobs()
if job_id:
all_jobs = [job for job in all_jobs if job.id == str(job_id)]
return all_jobs[offset:offset+limit]
except Exception as e:
logger.error(f"Error retrieving sync jobs: {e}", exc_info=True)
return []
return []

View File

@@ -1,6 +1,7 @@
import hashlib
from pathlib import Path
def calculate_sha256(file_path: Path) -> str:
"""Calculate the SHA256 checksum of a file."""
hasher = hashlib.sha256()