mirror of
https://github.com/sstent/FitTrack_GarminSync.git
synced 2026-01-29 10:31:39 +00:00
feat: Implement Garmin sync, login improvements, and utility scripts
This commit is contained in:
86
backend/src/api/garmin_auth.py
Normal file
86
backend/src/api/garmin_auth.py
Normal 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.")
|
||||
@@ -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),
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
46
backend/src/services/garmin_auth_service.py
Normal file
46
backend/src/services/garmin_auth_service.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user