feat: Implement single sync job management and progress tracking

This commit is contained in:
2025-10-11 18:36:19 -07:00
parent 3819e4f5e2
commit 723ca04aa8
51 changed files with 1625 additions and 596 deletions

View File

@@ -6,6 +6,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-10
- Python 3.13 + FastAPI, `garth`, `garminconnect`, `httpx`, `pydantic` (003-loginimprovements-use-the) - Python 3.13 + FastAPI, `garth`, `garminconnect`, `httpx`, `pydantic` (003-loginimprovements-use-the)
- Python 3.13 + FastAPI, garth, garminconnect, httpx, pydantic (003-loginimprovements-use-the) - Python 3.13 + FastAPI, garth, garminconnect, httpx, pydantic (003-loginimprovements-use-the)
- centralDB (PostgreSQL/SQLite with SQLAlchemy) (003-loginimprovements-use-the) - centralDB (PostgreSQL/SQLite with SQLAlchemy) (003-loginimprovements-use-the)
- Python 3.13 + FastAPI, Pydantic, `garth`, `garminconnect`, `httpx` (004-home-sstent-projects)
- In-memory for `CurrentSyncJobManager` (004-home-sstent-projects)
## Project Structure ## Project Structure
``` ```
@@ -20,6 +22,7 @@ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLO
Python 3.13: Follow standard conventions Python 3.13: Follow standard conventions
## Recent Changes ## Recent Changes
- 004-home-sstent-projects: Added Python 3.13 + FastAPI, Pydantic, `garth`, `garminconnect`, `httpx`
- 003-loginimprovements-use-the: Added Python 3.13 + FastAPI, garth, garminconnect, httpx, pydantic - 003-loginimprovements-use-the: Added Python 3.13 + FastAPI, garth, garminconnect, httpx, pydantic
- 003-loginimprovements-use-the: Added Python 3.13 + FastAPI, `garth`, `garminconnect`, `httpx`, `pydantic` - 003-loginimprovements-use-the: Added Python 3.13 + FastAPI, `garth`, `garminconnect`, `httpx`, `pydantic`

View File

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

View File

@@ -1,47 +1,57 @@
from typing import List, Optional from typing import Optional
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from ..dependencies import ( from ..dependencies import (
get_current_user, get_current_user,
get_garmin_activity_service, get_garmin_activity_service,
get_garmin_health_service, # Added this line
get_garmin_workout_service, get_garmin_workout_service,
get_sync_status_service,
) )
from ..jobs import SyncJob, job_store from ..models.sync_job import SyncJob
from ..schemas import ActivitySyncRequest, User, WorkoutUploadRequest from ..schemas import ActivitySyncRequest, User, WorkoutUploadRequest
from ..services.garmin_activity_service import GarminActivityService from ..services.garmin_activity_service import GarminActivityService
from ..services.garmin_health_service import GarminHealthService
from ..services.garmin_workout_service import GarminWorkoutService from ..services.garmin_workout_service import GarminWorkoutService
from ..services.sync_status_service import SyncStatusService from ..services.sync_manager import current_sync_job_manager
router = APIRouter() router = APIRouter()
@router.post("/garmin/activities", response_model=SyncJob, status_code=202)
@router.post("/garmin/activities", status_code=202)
async def trigger_garmin_activity_sync( async def trigger_garmin_activity_sync(
request: ActivitySyncRequest, request: ActivitySyncRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
garmin_activity_service: GarminActivityService = Depends(get_garmin_activity_service), garmin_activity_service: GarminActivityService = Depends(
get_garmin_activity_service
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
): ):
""" """
Trigger Garmin Connect Activity Synchronization Trigger Garmin Connect Activity Synchronization
""" """
job = job_store.create_job() if await current_sync_job_manager.is_sync_active():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A synchronization is already in progress. Please wait or check status.",
)
await current_sync_job_manager.start_sync(job_type="activities")
background_tasks.add_task( background_tasks.add_task(
garmin_activity_service.sync_activities_in_background, garmin_activity_service.sync_activities_in_background,
job.id, current_user.user_id,
current_sync_job_manager,
request.force_resync, request.force_resync,
request.start_date, request.start_date,
request.end_date, request.end_date,
max_activities_to_sync # Pass the new parameter max_activities_to_sync, # Pass the new parameter
) )
return job return {"message": "Activity synchronization initiated successfully."}
@router.post("/garmin/workouts", response_model=SyncJob, status_code=202)
@router.post("/garmin/workouts", status_code=202)
async def upload_garmin_workout( async def upload_garmin_workout(
request: WorkoutUploadRequest, request: WorkoutUploadRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
@@ -51,31 +61,51 @@ async def upload_garmin_workout(
""" """
Upload a workout to Garmin Connect Upload a workout to Garmin Connect
""" """
job = job_store.create_job() if await current_sync_job_manager.is_sync_active():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A synchronization is already in progress. Please wait or check status.",
)
await current_sync_job_manager.start_sync(job_type="workouts")
background_tasks.add_task( background_tasks.add_task(
garmin_workout_service.upload_workout_in_background, garmin_workout_service.upload_workout_in_background,
job.id, current_user.user_id,
current_sync_job_manager,
request.workout_id, request.workout_id,
) )
return job return {"message": "Workout synchronization initiated successfully."}
@router.get("/status/{job_id}", response_model=List[SyncJob], status_code=200)
async def get_sync_status( @router.post("/garmin/health", status_code=202)
job_id: UUID, async def trigger_garmin_health_sync(
limit: int = 10, background_tasks: BackgroundTasks,
offset: int = 0, garmin_health_service: GarminHealthService = Depends(get_garmin_health_service),
sync_status_service: SyncStatusService = Depends(get_sync_status_service),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
Retrieve the status of synchronization jobs. Trigger Garmin Connect Health Metrics Synchronization
""" """
sync_jobs = sync_status_service.get_sync_jobs( if await current_sync_job_manager.is_sync_active():
job_id=job_id, raise HTTPException(
limit=limit, status_code=status.HTTP_409_CONFLICT,
offset=offset detail="A synchronization is already in progress. Please wait or check status.",
)
await current_sync_job_manager.start_sync(job_type="health")
background_tasks.add_task(
garmin_health_service.sync_health_metrics_in_background,
current_user.user_id,
current_sync_job_manager,
) )
return sync_jobs return {"message": "Health metrics synchronization initiated successfully."}
@router.get("/garmin/sync/status", response_model=SyncJob, status_code=200)
async def get_garmin_sync_status():
"""
Retrieve the current status of the single active synchronization job.
"""
return await current_sync_job_manager.get_current_sync_status()

View File

@@ -6,7 +6,9 @@ import httpx
from ... import errors from ... import errors
from ...client import AuthenticatedClient, Client from ...client import AuthenticatedClient, Client
from ...models.activity import Activity from ...models.activity import Activity
from ...models.body_upload_activity_activities_post import BodyUploadActivityActivitiesPost from ...models.body_upload_activity_activities_post import (
BodyUploadActivityActivitiesPost,
)
from ...models.http_validation_error import HTTPValidationError from ...models.http_validation_error import HTTPValidationError
from ...types import Response from ...types import Response

View File

@@ -17,7 +17,9 @@ def _get_kwargs() -> dict[str, Any]:
return _kwargs return _kwargs
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]: def _parse_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Optional[Any]:
if response.status_code == 200: if response.status_code == 200:
return None return None
@@ -27,7 +29,9 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
return None return None
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]: def _build_response(
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
) -> Response[Any]:
return Response( return Response(
status_code=HTTPStatus(response.status_code), status_code=HTTPStatus(response.status_code),
content=response.content, content=response.content,

View File

@@ -38,9 +38,15 @@ class Client:
_base_url: str = field(alias="base_url") _base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") _timeout: Optional[httpx.Timeout] = field(
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") default=None, kw_only=True, alias="timeout"
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") )
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(
default=True, kw_only=True, alias="verify_ssl"
)
_follow_redirects: bool = field(
default=False, kw_only=True, alias="follow_redirects"
)
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: Optional[httpx.Client] = field(default=None, init=False) _client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
@@ -168,9 +174,15 @@ class AuthenticatedClient:
_base_url: str = field(alias="base_url") _base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") _timeout: Optional[httpx.Timeout] = field(
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") default=None, kw_only=True, alias="timeout"
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") )
_verify_ssl: Union[str, bool, ssl.SSLContext] = field(
default=True, kw_only=True, alias="verify_ssl"
)
_follow_redirects: bool = field(
default=False, kw_only=True, alias="follow_redirects"
)
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: Optional[httpx.Client] = field(default=None, init=False) _client: Optional[httpx.Client] = field(default=None, init=False)
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
@@ -214,7 +226,9 @@ class AuthenticatedClient:
def get_httpx_client(self) -> httpx.Client: def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set""" """Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None: if self._client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token self._headers[self.auth_header_name] = (
f"{self.prefix} {self.token}" if self.prefix else self.token
)
self._client = httpx.Client( self._client = httpx.Client(
base_url=self._base_url, base_url=self._base_url,
cookies=self._cookies, cookies=self._cookies,
@@ -235,7 +249,9 @@ class AuthenticatedClient:
"""Exit a context manager for internal httpx.Client (see httpx docs)""" """Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs) self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": def set_async_httpx_client(
self, async_client: httpx.AsyncClient
) -> "AuthenticatedClient":
"""Manually the underlying httpx.AsyncClient """Manually the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
@@ -246,7 +262,9 @@ class AuthenticatedClient:
def get_async_httpx_client(self) -> httpx.AsyncClient: def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" """Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None: if self._async_client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token self._headers[self.auth_header_name] = (
f"{self.prefix} {self.token}" if self.prefix else self.token
)
self._async_client = httpx.AsyncClient( self._async_client = httpx.AsyncClient(
base_url=self._base_url, base_url=self._base_url,
cookies=self._cookies, cookies=self._cookies,

View File

@@ -34,7 +34,9 @@ class Activity:
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
from ..models.activity_activity_metadata_type_0 import ActivityActivityMetadataType0 from ..models.activity_activity_metadata_type_0 import (
ActivityActivityMetadataType0,
)
user_id = self.user_id user_id = self.user_id
@@ -69,7 +71,9 @@ class Activity:
@classmethod @classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.activity_activity_metadata_type_0 import ActivityActivityMetadataType0 from ..models.activity_activity_metadata_type_0 import (
ActivityActivityMetadataType0,
)
d = dict(src_dict) d = dict(src_dict)
user_id = d.pop("user_id") user_id = d.pop("user_id")
@@ -80,7 +84,9 @@ class Activity:
created_at = isoparse(d.pop("created_at")) created_at = isoparse(d.pop("created_at"))
def _parse_activity_metadata(data: object) -> Union["ActivityActivityMetadataType0", None, Unset]: def _parse_activity_metadata(
data: object,
) -> Union["ActivityActivityMetadataType0", None, Unset]:
if data is None: if data is None:
return data return data
if isinstance(data, Unset): if isinstance(data, Unset):

View File

@@ -5,7 +5,9 @@ from attrs import define as _attrs_define
from attrs import field as _attrs_field from attrs import field as _attrs_field
if TYPE_CHECKING: if TYPE_CHECKING:
from ..models.coaching_session_create_conversation import CoachingSessionCreateConversation from ..models.coaching_session_create_conversation import (
CoachingSessionCreateConversation,
)
T = TypeVar("T", bound="CoachingSessionCreate") T = TypeVar("T", bound="CoachingSessionCreate")
@@ -36,10 +38,14 @@ class CoachingSessionCreate:
@classmethod @classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.coaching_session_create_conversation import CoachingSessionCreateConversation from ..models.coaching_session_create_conversation import (
CoachingSessionCreateConversation,
)
d = dict(src_dict) d = dict(src_dict)
conversation = CoachingSessionCreateConversation.from_dict(d.pop("conversation")) conversation = CoachingSessionCreateConversation.from_dict(
d.pop("conversation")
)
coaching_session_create = cls( coaching_session_create = cls(
conversation=conversation, conversation=conversation,

View File

@@ -71,7 +71,9 @@ class User:
id = d.pop("id") id = d.pop("id")
def _parse_preferences(data: object) -> Union["UserPreferencesType0", None, Unset]: def _parse_preferences(
data: object,
) -> Union["UserPreferencesType0", None, Unset]:
if data is None: if data is None:
return data return data
if isinstance(data, Unset): if isinstance(data, Unset):

View File

@@ -64,7 +64,9 @@ class UserCreate:
email = d.pop("email") email = d.pop("email")
def _parse_preferences(data: object) -> Union["UserCreatePreferencesType0", None, Unset]: def _parse_preferences(
data: object,
) -> Union["UserCreatePreferencesType0", None, Unset]:
if data is None: if data is None:
return data return data
if isinstance(data, Unset): if isinstance(data, Unset):

View File

@@ -86,7 +86,9 @@ class UserUpdate:
email = _parse_email(d.pop("email", UNSET)) email = _parse_email(d.pop("email", UNSET))
def _parse_preferences(data: object) -> Union["UserUpdatePreferencesType0", None, Unset]: def _parse_preferences(
data: object,
) -> Union["UserUpdatePreferencesType0", None, Unset]:
if data is None: if data is None:
return data return data
if isinstance(data, Unset): if isinstance(data, Unset):

View File

@@ -41,7 +41,9 @@ class WorkoutPlanCreate:
@classmethod @classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.workout_plan_create_plan_details import WorkoutPlanCreatePlanDetails from ..models.workout_plan_create_plan_details import (
WorkoutPlanCreatePlanDetails,
)
d = dict(src_dict) d = dict(src_dict)
user_id = d.pop("user_id") user_id = d.pop("user_id")

View File

@@ -7,7 +7,9 @@ from attrs import field as _attrs_field
from ..types import UNSET, Unset from ..types import UNSET, Unset
if TYPE_CHECKING: if TYPE_CHECKING:
from ..models.workout_plan_update_plan_details_type_0 import WorkoutPlanUpdatePlanDetailsType0 from ..models.workout_plan_update_plan_details_type_0 import (
WorkoutPlanUpdatePlanDetailsType0,
)
T = TypeVar("T", bound="WorkoutPlanUpdate") T = TypeVar("T", bound="WorkoutPlanUpdate")
@@ -24,7 +26,9 @@ class WorkoutPlanUpdate:
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
from ..models.workout_plan_update_plan_details_type_0 import WorkoutPlanUpdatePlanDetailsType0 from ..models.workout_plan_update_plan_details_type_0 import (
WorkoutPlanUpdatePlanDetailsType0,
)
plan_details: Union[None, Unset, dict[str, Any]] plan_details: Union[None, Unset, dict[str, Any]]
if isinstance(self.plan_details, Unset): if isinstance(self.plan_details, Unset):
@@ -44,11 +48,15 @@ class WorkoutPlanUpdate:
@classmethod @classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.workout_plan_update_plan_details_type_0 import WorkoutPlanUpdatePlanDetailsType0 from ..models.workout_plan_update_plan_details_type_0 import (
WorkoutPlanUpdatePlanDetailsType0,
)
d = dict(src_dict) d = dict(src_dict)
def _parse_plan_details(data: object) -> Union["WorkoutPlanUpdatePlanDetailsType0", None, Unset]: def _parse_plan_details(
data: object,
) -> Union["WorkoutPlanUpdatePlanDetailsType0", None, Unset]:
if data is None: if data is None:
return data return data
if isinstance(data, Unset): if isinstance(data, Unset):

View File

@@ -1,11 +1,12 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Tuple from typing import Optional, Tuple
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
APP_NAME: str = "GarminSync Backend" APP_NAME: str = "GarminSync Backend"
DEBUG: bool = False DEBUG: bool = False
@@ -18,12 +19,14 @@ class Settings(BaseSettings):
GARMIN_CONNECT_EMAIL: str = "" GARMIN_CONNECT_EMAIL: str = ""
GARMIN_CONNECT_PASSWORD: str = "" GARMIN_CONNECT_PASSWORD: str = ""
CENTRAL_DB_URL: str CENTRAL_DB_URL: str
DATABASE_URL: Optional[str] = None # Added to handle potential old .env variable
GARMINSYNC_DATA_DIR: Path = Path("data") GARMINSYNC_DATA_DIR: Path = Path("data")
class Config: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
settings = Settings() settings = Settings()
# Create data directory if it doesn't exist # Create data directory if it doesn't exist
@@ -31,6 +34,7 @@ settings = Settings()
_deprecation_warned = False _deprecation_warned = False
def get_garmin_credentials() -> Tuple[str, str]: def get_garmin_credentials() -> Tuple[str, str]:
"""Get Garmin Connect credentials from environment variables. """Get Garmin Connect credentials from environment variables.

View File

@@ -10,62 +10,66 @@ from .services.garmin_auth_service import GarminAuthService # New import
from .services.garmin_client_service import GarminClientService, garmin_client_service from .services.garmin_client_service import GarminClientService, garmin_client_service
from .services.garmin_health_service import GarminHealthService from .services.garmin_health_service import GarminHealthService
from .services.garmin_workout_service import GarminWorkoutService from .services.garmin_workout_service import GarminWorkoutService
from .services.sync_status_service import SyncStatusService
def get_central_db_service() -> CentralDBService: def get_central_db_service() -> CentralDBService:
return CentralDBService(base_url=settings.CENTRAL_DB_URL) return CentralDBService(base_url=settings.CENTRAL_DB_URL)
def get_auth_service() -> AuthService: def get_auth_service() -> AuthService:
return AuthService() return AuthService()
def get_garmin_auth_service() -> GarminAuthService: # New dependency function
def get_garmin_auth_service() -> GarminAuthService: # New dependency function
return GarminAuthService() return GarminAuthService()
def get_garmin_client_service() -> GarminClientService: def get_garmin_client_service() -> GarminClientService:
return garmin_client_service return garmin_client_service
def get_activity_download_service( def get_activity_download_service(
garmin_client_service: GarminClientService = Depends(get_garmin_client_service) garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
) -> ActivityDownloadService: ) -> ActivityDownloadService:
return ActivityDownloadService(garmin_client_instance=garmin_client_service) return ActivityDownloadService(garmin_client_instance=garmin_client_service)
def get_garmin_activity_service( def get_garmin_activity_service(
garmin_client_service: GarminClientService = Depends(get_garmin_client_service), garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
activity_download_service: ActivityDownloadService = Depends(get_activity_download_service), activity_download_service: ActivityDownloadService = Depends(
get_activity_download_service
),
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service), garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
central_db_service: CentralDBService = Depends(get_central_db_service) central_db_service: CentralDBService = Depends(get_central_db_service),
) -> GarminActivityService: ) -> GarminActivityService:
return GarminActivityService( return GarminActivityService(
garmin_client_service=garmin_client_service, garmin_client_service=garmin_client_service,
activity_download_service=activity_download_service, activity_download_service=activity_download_service,
garmin_auth_service=garmin_auth_service, garmin_auth_service=garmin_auth_service,
central_db_service=central_db_service central_db_service=central_db_service,
) )
def get_garmin_health_service( def get_garmin_health_service(
garmin_client_service: GarminClientService = Depends(get_garmin_client_service), garmin_client_service: GarminClientService = Depends(get_garmin_client_service),
central_db_service: CentralDBService = Depends(get_central_db_service), central_db_service: CentralDBService = Depends(get_central_db_service),
garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service) garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service),
) -> GarminHealthService: ) -> GarminHealthService:
return GarminHealthService( return GarminHealthService(
garmin_client_service=garmin_client_service, garmin_client_service=garmin_client_service,
central_db_service=central_db_service, central_db_service=central_db_service,
garmin_auth_service=garmin_auth_service garmin_auth_service=garmin_auth_service,
) )
def get_garmin_workout_service() -> GarminWorkoutService: def get_garmin_workout_service() -> GarminWorkoutService:
return GarminWorkoutService(garmin_client_service) # Assuming it needs 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( async def get_current_user(
central_db_service: CentralDBService = Depends(get_central_db_service) central_db_service: CentralDBService = Depends(get_central_db_service),
) -> User: ) -> User:
# As per spec, this is a single-user system, so we can assume user_id = 1 # As per spec, this is a single-user system, so we can assume user_id = 1
user_id = 1 user_id = 1

View File

@@ -1,58 +0,0 @@
import uuid
from datetime import datetime
from threading import Lock
from typing import Dict, Optional
from pydantic import BaseModel, Field
class SyncJob(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
status: str = "pending"
start_time: datetime = Field(default_factory=datetime.utcnow)
end_time: Optional[datetime] = None
progress: float = 0.0
error_message: Optional[str] = None
details: Dict = Field(default_factory=dict)
class JobStore:
def __init__(self):
self._jobs: Dict[str, SyncJob] = {}
self._lock = Lock()
def create_job(self) -> SyncJob:
job = SyncJob()
with self._lock:
self._jobs[job.id] = job
return job
def get_job(self, job_id: str) -> Optional[SyncJob]:
with self._lock:
return self._jobs.get(job_id)
def get_all_jobs(self) -> list[SyncJob]:
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
):
with self._lock:
job = self._jobs.get(job_id)
if job:
job.status = status
job.progress = progress
if details:
job.details = details
if error_message:
job.error_message = error_message
if status in ["completed", "failed"]:
job.end_time = datetime.utcnow()
job_store = JobStore()

View File

@@ -2,7 +2,7 @@ import logging
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
def setup_logging(): def setup_logging():
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
# You can add more sophisticated logging handlers here, e.g., file handlers, Sentry, etc. # You can add more sophisticated logging handlers here, e.g., file handlers, Sentry, etc.

View File

@@ -20,7 +20,9 @@ app = FastAPI(title=settings.APP_NAME)
rate_limiter = RateLimiter(rate_limit="100/minute") rate_limiter = RateLimiter(rate_limit="100/minute")
# Initialize CentralDBService # Initialize CentralDBService
central_db_service = CentralDBService(base_url=settings.CENTRAL_DB_URL) # Assuming CENTRAL_DB_URL in settings 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"]) 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.include_router(garmin_auth.router, prefix="/api/garmin", tags=["Garmin Auth"])
@@ -52,6 +54,7 @@ app.include_router(garmin_auth.router, prefix="/api/garmin", tags=["Garmin Auth"
# ) # )
# return {"message": "Login successful"} # return {"message": "Login successful"}
@app.post("/logout") @app.post("/logout")
async def logout(response: Response): async def logout(response: Response):
response.delete_cookie(key=settings.SESSION_COOKIE_NAME) response.delete_cookie(key=settings.SESSION_COOKIE_NAME)
@@ -65,14 +68,18 @@ async def general_exception_handler(request: Request, exc: Exception):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={ content={
"message": "An unexpected error occurred.", "message": "An unexpected error occurred.",
"detail": str(exc) if settings.DEBUG else None, # Only show detail in debug mode "detail": (
str(exc) if settings.DEBUG else None
), # Only show detail in debug mode
}, },
) )
@app.get("/") @app.get("/")
async def root(): async def root():
return {"message": "Welcome to GarminSync Backend!"} return {"message": "Welcome to GarminSync Backend!"}
@app.post("/background-test", dependencies=[Depends(rate_limiter)]) @app.post("/background-test", dependencies=[Depends(rate_limiter)])
async def run_background_test(background_tasks: BackgroundTasks): async def run_background_test(background_tasks: BackgroundTasks):
message = "This is a test background task." message = "This is a test background task."

View File

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel
SyncJobStatus = Literal["pending", "in_progress", "completed", "failed"]
SyncJobType = Literal["activities", "health", "workouts"]
class SyncJob(BaseModel):
status: SyncJobStatus = "pending"
progress: float = 0.0
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
error_message: Optional[str] = None
job_type: Optional[SyncJobType] = None

View File

@@ -8,9 +8,11 @@ class UserBase(BaseModel):
name: str name: str
email: str email: str
class UserCreate(UserBase): class UserCreate(UserBase):
pass pass
class User(UserBase): class User(UserBase):
id: int id: int
preferences: Optional[Dict[str, Any]] = None preferences: Optional[Dict[str, Any]] = None
@@ -18,17 +20,21 @@ class User(UserBase):
class Config: class Config:
from_attributes = True from_attributes = True
class TokenBase(BaseModel): class TokenBase(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
expires_at: int # Unix timestamp expires_at: int # Unix timestamp
class TokenCreate(TokenBase): class TokenCreate(TokenBase):
user_id: int user_id: int
class TokenUpdate(TokenBase): class TokenUpdate(TokenBase):
user_id: int user_id: int
class Token(TokenBase): class Token(TokenBase):
id: int id: int
user_id: int user_id: int
@@ -38,15 +44,18 @@ class Token(TokenBase):
class Config: class Config:
from_attributes = True from_attributes = True
class WorkoutPlan(BaseModel): class WorkoutPlan(BaseModel):
id: int id: int
user_id: int user_id: int
plan_details: Dict[str, Any] plan_details: Dict[str, Any]
created_at: datetime created_at: datetime
class ActivitySyncRequest(BaseModel): class ActivitySyncRequest(BaseModel):
force_resync: bool = Field( force_resync: bool = Field(
False, description="If true, re-download activities even if they exist. Defaults to false." False,
description="If true, re-download activities even if they exist. Defaults to false.",
) )
start_date: Optional[date] = Field( start_date: Optional[date] = Field(
None, None,
@@ -59,16 +68,22 @@ class ActivitySyncRequest(BaseModel):
None, description="Optional end date (YYYY-MM-DD) to sync activities up to." None, description="Optional end date (YYYY-MM-DD) to sync activities up to."
) )
class WorkoutUploadRequest(BaseModel): 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): class GarminCredentials(BaseModel):
garmin_username: str garmin_username: str
garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk. garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk.
class GarminLoginRequest(BaseModel): class GarminLoginRequest(BaseModel):
username: str username: str
password: str password: str
class GarminLoginResponse(BaseModel): class GarminLoginResponse(BaseModel):
message: str message: str

View File

@@ -8,11 +8,14 @@ from ..config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ActivityDownloadService: class ActivityDownloadService:
def __init__(self, garmin_client_instance): def __init__(self, garmin_client_instance):
self.garmin_client = garmin_client_instance self.garmin_client = garmin_client_instance
def download_activity_original(self, activity_id: str, force_download: bool = False) -> Optional[Path]: def download_activity_original(
self, activity_id: str, force_download: bool = False
) -> Optional[Path]:
"""Download original activity file (usually FIT format). """Download original activity file (usually FIT format).
Args: Args:
@@ -36,7 +39,7 @@ class ActivityDownloadService:
attempts: List[str] = [] attempts: List[str] = []
# 1) Prefer native method when available # 1) Prefer native method when available
if hasattr(self.garmin_client.client, 'download_activity_original'): if hasattr(self.garmin_client.client, "download_activity_original"):
try: try:
attempts.append( attempts.append(
"self.garmin_client.client.download_activity_original(activity_id)" "self.garmin_client.client.download_activity_original(activity_id)"
@@ -44,7 +47,9 @@ class ActivityDownloadService:
logger.debug( logger.debug(
f"Attempting native download_activity_original for activity {activity_id}" f"Attempting native download_activity_original for activity {activity_id}"
) )
file_data = self.garmin_client.client.download_activity_original(activity_id) file_data = self.garmin_client.client.download_activity_original(
activity_id
)
except Exception as e: except Exception as e:
logger.debug( logger.debug(
f"Native download_activity_original failed: {e} (type={type(e).__name__})" f"Native download_activity_original failed: {e} (type={type(e).__name__})"
@@ -52,7 +57,9 @@ class ActivityDownloadService:
file_data = None file_data = None
# 2) Try download_activity with 'original' format # 2) Try download_activity with 'original' format
if file_data is None and hasattr(self.garmin_client.client, 'download_activity'): if file_data is None and hasattr(
self.garmin_client.client, "download_activity"
):
try: try:
attempts.append( attempts.append(
"self.garmin_client.client.download_activity(activity_id, " "self.garmin_client.client.download_activity(activity_id, "
@@ -64,14 +71,19 @@ class ActivityDownloadService:
f"for activity {activity_id}" f"for activity {activity_id}"
) )
file_data = self.garmin_client.client.download_activity( file_data = self.garmin_client.client.download_activity(
activity_id, dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL activity_id,
dl_fmt=self.garmin_client.client.ActivityDownloadFormat.ORIGINAL,
) )
logger.debug( logger.debug(
f"download_activity(dl_fmt='original') succeeded, got data type: " f"download_activity(dl_fmt='original') succeeded, got data type: "
f"{type(file_data).__name__}, length: " f"{type(file_data).__name__}, length: "
f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}" 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: 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]}") logger.debug(f"First 100 bytes: {file_data[:100]}")
except Exception as e: except Exception as e:
logger.debug( logger.debug(
@@ -80,8 +92,10 @@ class ActivityDownloadService:
file_data = None file_data = None
# 3) Try download_activity with positional token (older signatures) # 3) Try download_activity with positional token (older signatures)
if file_data is None and hasattr(self.garmin_client.client, 'download_activity'): if file_data is None and hasattr(
tokens_to_try_pos = ['ORIGINAL', 'original', 'FIT', 'fit'] self.garmin_client.client, "download_activity"
):
tokens_to_try_pos = ["ORIGINAL", "original", "FIT", "fit"]
for token in tokens_to_try_pos: for token in tokens_to_try_pos:
try: try:
attempts.append( attempts.append(
@@ -91,13 +105,19 @@ class ActivityDownloadService:
"Attempting original download via download_activity(" "Attempting original download via download_activity("
f"activity_id, '{token}') for activity {activity_id}" f"activity_id, '{token}') for activity {activity_id}"
) )
file_data = self.garmin_client.client.download_activity(activity_id, token) file_data = self.garmin_client.client.download_activity(
activity_id, token
)
logger.debug( logger.debug(
f"download_activity(activity_id, '{token}') succeeded, got data type: " f"download_activity(activity_id, '{token}') succeeded, got data type: "
f"{type(file_data).__name__}, length: " f"{type(file_data).__name__}, length: "
f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}" 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: 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]}") logger.debug(f"First 100 bytes: {file_data[:100]}")
break break
except Exception as e: except Exception as e:
@@ -113,12 +133,12 @@ class ActivityDownloadService:
) )
return None return None
if hasattr(file_data, 'content'): if hasattr(file_data, "content"):
try: try:
file_data = file_data.content file_data = file_data.content
except Exception: except Exception:
pass pass
elif hasattr(file_data, 'read'): elif hasattr(file_data, "read"):
try: try:
file_data = file_data.read() file_data = file_data.read()
except Exception: except Exception:
@@ -136,21 +156,29 @@ class ActivityDownloadService:
tmp_file.write(file_data) tmp_file.write(file_data)
tmp_path = Path(tmp_file.name) tmp_path = Path(tmp_file.name)
extracted_path = settings.GARMINSYNC_DATA_DIR / f"activity_{activity_id}.fit" extracted_path = (
settings.GARMINSYNC_DATA_DIR / f"activity_{activity_id}.fit"
)
if zipfile.is_zipfile(tmp_path): if zipfile.is_zipfile(tmp_path):
with zipfile.ZipFile(tmp_path, 'r') as zip_ref: with zipfile.ZipFile(tmp_path, "r") as zip_ref:
fit_files = [f for f in zip_ref.namelist() if f.lower().endswith('.fit')] fit_files = [
f for f in zip_ref.namelist() if f.lower().endswith(".fit")
]
if fit_files: if fit_files:
fit_filename = fit_files[0] fit_filename = fit_files[0]
with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target: with zip_ref.open(fit_filename) as source, open(
extracted_path, "wb"
) as target:
target.write(source.read()) target.write(source.read())
tmp_path.unlink() tmp_path.unlink()
logger.info(f"Downloaded original activity file: {extracted_path}") logger.info(
f"Downloaded original activity file: {extracted_path}"
)
downloaded_path = extracted_path downloaded_path = extracted_path
else: else:
logger.warning("No FIT file found in downloaded archive") logger.warning("No FIT file found in downloaded archive")
@@ -164,7 +192,9 @@ class ActivityDownloadService:
f"Rename temp FIT to destination failed ({move_err}); " f"Rename temp FIT to destination failed ({move_err}); "
"falling back to copy" "falling back to copy"
) )
with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source: with open(extracted_path, "wb") as target, open(
tmp_path, "rb"
) as source:
target.write(source.read()) target.write(source.read())
tmp_path.unlink() tmp_path.unlink()
downloaded_path = extracted_path downloaded_path = extracted_path

View File

@@ -11,6 +11,7 @@ from .central_db_service import CentralDBService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AuthService: class AuthService:
def __init__(self): def __init__(self):
self.central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL) self.central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
@@ -24,13 +25,17 @@ class AuthService:
if not session_cookie: if not session_cookie:
return None return None
try: try:
user_id = self.serializer.loads(session_cookie, max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60) user_id = self.serializer.loads(
session_cookie, max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
user = await self.central_db.get_user(user_id=user_id) user = await self.central_db.get_user(user_id=user_id)
return user return user
except Exception: except Exception:
return None return None
async def authenticate_garmin_connect(self, email: str, password: str) -> Optional[User]: async def authenticate_garmin_connect(
self, email: str, password: str
) -> Optional[User]:
""" """
Authenticates with Garmin Connect, and returns the user object. Authenticates with Garmin Connect, and returns the user object.
""" """

View File

@@ -3,6 +3,7 @@ import time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def example_background_task(message: str): def example_background_task(message: str):
logger.info(f"Starting background task with message: {message}") logger.info(f"Starting background task with message: {message}")
time.sleep(5) # Simulate a long-running task time.sleep(5) # Simulate a long-running task

View File

@@ -1,11 +1,16 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional
import httpx import httpx
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 ..schemas import GarminCredentials, Token, User, WorkoutPlan from ..schemas import GarminCredentials, Token, User, WorkoutPlan
from ..config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,10 +18,11 @@ logger = logging.getLogger(__name__)
CENTRAL_DB_RETRY_STRATEGY = retry( CENTRAL_DB_RETRY_STRATEGY = retry(
stop=stop_after_attempt(5), stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=2, max=10), wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors
reraise=True reraise=True,
) )
class CentralDBService: class CentralDBService:
def __init__(self, base_url: str): def __init__(self, base_url: str):
self.base_url = base_url self.base_url = base_url
@@ -73,7 +79,9 @@ class CentralDBService:
async def create_token(self, token_create: dict) -> Optional[Token]: async def create_token(self, token_create: dict) -> Optional[Token]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post(f"{self.base_url}/tokens/", json=token_create) response = await client.post(
f"{self.base_url}/tokens/", json=token_create
)
response.raise_for_status() response.raise_for_status()
return Token(**response.json()) return Token(**response.json())
except Exception as e: except Exception as e:
@@ -84,7 +92,9 @@ class CentralDBService:
async def update_token(self, user_id: int, token_update: dict) -> Optional[Token]: async def update_token(self, user_id: int, token_update: dict) -> Optional[Token]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.put(f"{self.base_url}/tokens/{user_id}", json=token_update) response = await client.put(
f"{self.base_url}/tokens/{user_id}", json=token_update
)
response.raise_for_status() response.raise_for_status()
return Token(**response.json()) return Token(**response.json())
except Exception as e: except Exception as e:
@@ -95,7 +105,9 @@ class CentralDBService:
async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]: async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(f"{self.base_url}/workout_plans/{workout_id}") response = await client.get(
f"{self.base_url}/workout_plans/{workout_id}"
)
response.raise_for_status() response.raise_for_status()
return WorkoutPlan(**response.json()) return WorkoutPlan(**response.json())
except Exception as e: except Exception as e:
@@ -108,24 +120,33 @@ class CentralDBService:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
files = {"file": (file_path.name, f, "application/fit")} # Changed content type files = {
user_id = 1 # Assuming single user for now "file": (file_path.name, f, "application/fit")
} # Changed content type
user_id = 1 # Assuming single user for now
response = await client.post( response = await client.post(
f"{self.base_url}/activities/{user_id}", # user_id as path parameter f"{self.base_url}/activities/{user_id}", # user_id as path parameter
files=files, files=files,
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Successfully uploaded activity {activity_id} to CentralDB.") logger.info(
f"Successfully uploaded activity {activity_id} to CentralDB."
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error uploading activity {activity_id} to CentralDB: {e}", exc_info=True) logger.error(
f"Error uploading activity {activity_id} to CentralDB: {e}",
exc_info=True,
)
return False return False
@CENTRAL_DB_RETRY_STRATEGY @CENTRAL_DB_RETRY_STRATEGY
async def save_health_metric(self, health_metric_data: dict) -> Optional[dict]: async def save_health_metric(self, health_metric_data: dict) -> Optional[dict]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post(f"{self.base_url}/health_metrics", json=health_metric_data) response = await client.post(
f"{self.base_url}/health_metrics", json=health_metric_data
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
@@ -136,7 +157,9 @@ class CentralDBService:
async def get_garmin_credentials(self, user_id: int) -> Optional[GarminCredentials]: async def get_garmin_credentials(self, user_id: int) -> Optional[GarminCredentials]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(f"{self.base_url}/garmin_credentials/{user_id}") response = await client.get(
f"{self.base_url}/garmin_credentials/{user_id}"
)
response.raise_for_status() response.raise_for_status()
return GarminCredentials(**response.json()) return GarminCredentials(**response.json())
except Exception as e: except Exception as e:
@@ -144,10 +167,15 @@ class CentralDBService:
return None return None
@CENTRAL_DB_RETRY_STRATEGY @CENTRAL_DB_RETRY_STRATEGY
async def create_garmin_credentials(self, user_id: int, credentials_data: dict) -> Optional[GarminCredentials]: async def create_garmin_credentials(
self, user_id: int, credentials_data: dict
) -> Optional[GarminCredentials]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post(f"{self.base_url}/garmin_credentials/{user_id}", json=credentials_data) response = await client.post(
f"{self.base_url}/garmin_credentials/{user_id}",
json=credentials_data,
)
response.raise_for_status() response.raise_for_status()
return GarminCredentials(**response.json()) return GarminCredentials(**response.json())
except Exception as e: except Exception as e:
@@ -155,10 +183,15 @@ class CentralDBService:
return None return None
@CENTRAL_DB_RETRY_STRATEGY @CENTRAL_DB_RETRY_STRATEGY
async def update_garmin_credentials(self, user_id: int, credentials_data: dict) -> Optional[GarminCredentials]: async def update_garmin_credentials(
self, user_id: int, credentials_data: dict
) -> Optional[GarminCredentials]:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.put(f"{self.base_url}/garmin_credentials/{user_id}", json=credentials_data) response = await client.put(
f"{self.base_url}/garmin_credentials/{user_id}",
json=credentials_data,
)
response.raise_for_status() response.raise_for_status()
return GarminCredentials(**response.json()) return GarminCredentials(**response.json())
except Exception as e: except Exception as e:

View File

@@ -3,9 +3,13 @@ import logging
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from ..jobs import job_store
from ..services.activity_download_service import ActivityDownloadService from ..services.activity_download_service import ActivityDownloadService
from ..services.central_db_service import CentralDBService from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService from ..services.garmin_auth_service import GarminAuthService
@@ -17,15 +21,17 @@ logger = logging.getLogger(__name__)
GARMIN_RETRY_STRATEGY = retry( GARMIN_RETRY_STRATEGY = retry(
stop=stop_after_attempt(5), stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=4, max=10), wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
reraise=True reraise=True,
) )
# Placeholder for SHA256 calculation - to be implemented in a utility module # Placeholder for SHA256 calculation - to be implemented in a utility module
def calculate_sha256(file_path) -> str: def calculate_sha256(file_path) -> str:
# This is a placeholder. Actual implementation would read the file and compute SHA256. # This is a placeholder. Actual implementation would read the file and compute SHA256.
return "mock_sha256_checksum" return "mock_sha256_checksum"
class GarminActivityService: class GarminActivityService:
def __init__( def __init__(
@@ -33,14 +39,16 @@ class GarminActivityService:
garmin_client_service: GarminClientService, garmin_client_service: GarminClientService,
activity_download_service: ActivityDownloadService, activity_download_service: ActivityDownloadService,
garmin_auth_service: GarminAuthService, garmin_auth_service: GarminAuthService,
central_db_service: CentralDBService central_db_service: CentralDBService,
): ):
self.garmin_client_service = garmin_client_service self.garmin_client_service = garmin_client_service
self.activity_download_service = activity_download_service self.activity_download_service = activity_download_service
self.garmin_auth_service = garmin_auth_service self.garmin_auth_service = garmin_auth_service
self.central_db_service = central_db_service self.central_db_service = central_db_service
async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]: async def _get_authenticated_garmin_client(
self, user_id: int
) -> Optional[GarminClientService]:
credentials = await self.central_db_service.get_garmin_credentials(user_id) credentials = await self.central_db_service.get_garmin_credentials(user_id)
if not credentials: if not credentials:
logger.error(f"No Garmin credentials found for user {user_id}.") logger.error(f"No Garmin credentials found for user {user_id}.")
@@ -55,17 +63,24 @@ class GarminActivityService:
# Check if the client is authenticated after updating credentials # Check if the client is authenticated after updating credentials
if not self.garmin_client_service.is_authenticated(): if not self.garmin_client_service.is_authenticated():
if not self.garmin_client_service.authenticate(): if not self.garmin_client_service.authenticate():
logger.error(f"Failed to authenticate Garmin client for user {user_id}.") logger.error(
f"Failed to authenticate Garmin client for user {user_id}."
)
return None return None
return self.garmin_client_service return self.garmin_client_service
@GARMIN_RETRY_STRATEGY @GARMIN_RETRY_STRATEGY
async def download_and_save_activity( async def download_and_save_activity(
self, user_id: int, activity_id: str, force_download: bool = False, self,
garmin_client: Optional[GarminClientService] = None # New argument user_id: int,
activity_id: str,
force_download: bool = False,
garmin_client: Optional[GarminClientService] = None, # New argument
) -> Optional[dict]: ) -> Optional[dict]:
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id) _garmin_client = garmin_client or await self._get_authenticated_garmin_client(
user_id
)
if not _garmin_client: if not _garmin_client:
return None return None
@@ -75,37 +90,49 @@ class GarminActivityService:
# CentralDB will be responsible for handling duplicates. # CentralDB will be responsible for handling duplicates.
# Download the original activity file (FIT) # Download the original activity file (FIT)
downloaded_file_path = self.activity_download_service.download_activity_original( downloaded_file_path = (
activity_id=activity_id, self.activity_download_service.download_activity_original(
force_download=force_download activity_id=activity_id, force_download=force_download
)
) )
if not downloaded_file_path: if not downloaded_file_path:
logger.error(f"Failed to download activity file for activity ID: {activity_id}") logger.error(
f"Failed to download activity file for activity ID: {activity_id}"
)
return None return None
# Upload the file to CentralDB # Upload the file to CentralDB
# from .central_db_service import CentralDBService # No longer needed, injected # from .central_db_service import CentralDBService # No longer needed, injected
# central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL) # central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
success = await self.central_db_service.upload_activity_file(activity_id, downloaded_file_path) success = await self.central_db_service.upload_activity_file(
activity_id, downloaded_file_path
)
if not success: if not success:
logger.error(f"Failed to upload activity file to CentralDB for activity ID: {activity_id}") logger.error(
f"Failed to upload activity file to CentralDB for activity ID: {activity_id}"
)
return None return None
# Get activity details from Garmin Connect (to extract metadata) # Get activity details from Garmin Connect (to extract metadata)
garmin_activity_details = _garmin_client.get_client().get_activity_details(activity_id) garmin_activity_details = _garmin_client.get_client().get_activity_details(
activity_id
)
return garmin_activity_details return garmin_activity_details
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error downloading and saving activity {activity_id}: {e}", exc_info=True f"Error downloading and saving activity {activity_id}: {e}",
exc_info=True,
) )
return None return None
@GARMIN_RETRY_STRATEGY @GARMIN_RETRY_STRATEGY
async def get_activities_for_sync(self, user_id: int, 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.""" """Get a list of recent activities from Garmin Connect."""
garmin_client = await self._get_authenticated_garmin_client(user_id) garmin_client = await self._get_authenticated_garmin_client(user_id)
if not garmin_client: if not garmin_client:
@@ -122,20 +149,22 @@ class GarminActivityService:
async def sync_activities_in_background( async def sync_activities_in_background(
self, self,
job_id: str, user_id: int,
current_sync_job_manager,
force_resync: bool = False, force_resync: bool = False,
start_date: Optional[date] = None, start_date: Optional[date] = None,
end_date: Optional[date] = None, end_date: Optional[date] = None,
max_activities_to_sync: Optional[int] = 10, # Default to 10 activities max_activities_to_sync: Optional[int] = 10, # Default to 10 activities
): ):
user_id = 1 # Assuming single user for now
try: try:
job_store.update_job(job_id, status="in_progress", progress=0.0) # user_id = 1 # Assuming single user for now - now passed as argument
# Authenticate Garmin client once at the beginning # Authenticate Garmin client once at the beginning
garmin_client = await self._get_authenticated_garmin_client(user_id) garmin_client = await self._get_authenticated_garmin_client(user_id)
if not garmin_client: if not garmin_client:
raise Exception("Garmin client not authenticated or failed to get valid credentials.") raise Exception(
"Garmin client not authenticated or failed to get valid credentials."
)
all_garmin_activities = [] all_garmin_activities = []
start = 0 start = 0
@@ -148,7 +177,7 @@ class GarminActivityService:
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to fetch activities from Garmin Connect after retries: {e}", f"Failed to fetch activities from Garmin Connect after retries: {e}",
exc_info=True exc_info=True,
) )
raise raise
@@ -158,21 +187,37 @@ class GarminActivityService:
start += limit start += limit
# Break if we have collected enough activities # Break if we have collected enough activities
if max_activities_to_sync is not None and len(all_garmin_activities) >= max_activities_to_sync: if (
all_garmin_activities = all_garmin_activities[:max_activities_to_sync] # Truncate to the limit 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 break
activities_to_process = [] activities_to_process = []
for activity_data in all_garmin_activities: for activity_data in all_garmin_activities:
activity_start_time_str = activity_data.get("startTimeGMT") activity_start_time_str = activity_data.get("startTimeGMT")
activity_start_time = ( activity_start_time = (
datetime.fromisoformat(activity_start_time_str.replace("Z", "+00:00")) datetime.fromisoformat(
if activity_start_time_str else None 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: if (
start_date
and activity_start_time
and activity_start_time.date() < start_date
):
continue continue
if end_date and activity_start_time and activity_start_time.date() > end_date: if (
end_date
and activity_start_time
and activity_start_time.date() > end_date
):
continue continue
activities_to_process.append(activity_data) activities_to_process.append(activity_data)
@@ -187,7 +232,7 @@ class GarminActivityService:
user_id=user_id, user_id=user_id,
activity_id=str(activity_data.get("activityId")), activity_id=str(activity_data.get("activityId")),
force_download=force_resync, force_download=force_resync,
garmin_client=garmin_client # Pass the authenticated client garmin_client=garmin_client, # Pass the authenticated client
) )
) )
@@ -199,20 +244,12 @@ class GarminActivityService:
logger.error(f"Failed to process activity {activity_id}: {result}") logger.error(f"Failed to process activity {activity_id}: {result}")
else: else:
synced_count += 1 synced_count += 1
job_store.update_job(job_id, status="in_progress", progress=(i + 1) / total_activities) await current_sync_job_manager.update_progress(
progress=(i + 1) / total_activities
)
job_store.update_job( await current_sync_job_manager.complete_sync()
job_id,
status="completed",
progress=1.0,
details={
"synced_activities_count": synced_count,
"total_activities_found": total_activities
}
)
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error during activity synchronization: {e}", exc_info=True)
f"Error during activity synchronization for job {job_id}: {e}", exc_info=True await current_sync_job_manager.fail_sync(error_message=str(e))
)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))

View File

@@ -1,9 +1,13 @@
import logging import logging
import asyncio # Import asyncio for sleep
from typing import Optional from typing import Optional
from garminconnect import Garmin from garminconnect import Garmin
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 ..schemas import GarminCredentials from ..schemas import GarminCredentials
@@ -13,34 +17,49 @@ logger = logging.getLogger(__name__)
# Define a retry strategy for Garmin login # Define a retry strategy for Garmin login
GARMIN_LOGIN_RETRY_STRATEGY = retry( GARMIN_LOGIN_RETRY_STRATEGY = retry(
stop=stop_after_attempt(10), # Increased attempts stop=stop_after_attempt(10), # Increased attempts
wait=wait_exponential(multiplier=1, min=10, max=60), # Increased min and max wait times wait=wait_exponential(
retry=retry_if_exception_type(Exception), # Retry on any exception for now multiplier=1, min=10, max=60
reraise=True ), # Increased min and max wait times
retry=retry_if_exception_type(Exception), # Retry on any exception for now
reraise=True,
) )
class GarminAuthService: class GarminAuthService:
def __init__(self): def __init__(self):
pass pass
@GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here @GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here
async def _perform_login(self, username: str, password: str) -> Garmin: async def _perform_login(self, username: str, password: str) -> Garmin:
"""Helper to perform the actual garminconnect login with retry.""" """Helper to perform the actual garminconnect login with retry."""
client = Garmin(username, password) client = Garmin(username, password)
client.login() client.login()
return client return client
async def initial_login(self, username: str, password: str) -> Optional[GarminCredentials]: async def initial_login(
self, username: str, password: str
) -> Optional[GarminCredentials]:
"""Performs initial login to Garmin Connect and returns GarminCredentials.""" """Performs initial login to Garmin Connect and returns GarminCredentials."""
try: try:
client = await self._perform_login(username, password) # Use the retried login helper garmin_client = await self._perform_login(
username, password
) # Use the retried login helper
if not garmin_client:
return None
logger.info(f"Successful Garmin login for {username}") logger.info(f"Successful Garmin login for {username}")
return GarminCredentials( # Extract tokens and cookies
garmin_credentials = GarminCredentials(
garmin_username=username, garmin_username=username,
garmin_password_plaintext=password, # Storing plaintext as per user requirement garmin_password_plaintext=password, # Storing plaintext for re-auth, consider encryption
display_name=garmin_client.display_name,
full_name=garmin_client.full_name,
unit_system=garmin_client.unit_system,
token_dict=garmin_client.garth.dump(), # Use garth.dump() to get the token dictionary
) )
return garmin_credentials
except Exception as e: except Exception as e:
logger.error(f"Garmin initial login failed for {username}: {e}") logger.error(f"Garmin initial login failed for {username}: {e}")
return None return None

View File

@@ -1,11 +1,12 @@
import logging import logging
from datetime import datetime # Import datetime
from typing import Optional from typing import Optional
from datetime import datetime # Import datetime
from garminconnect import Garmin from garminconnect import Garmin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GarminClientService: class GarminClientService:
def __init__(self): def __init__(self):
self.client: Optional[Garmin] = None self.client: Optional[Garmin] = None
@@ -18,7 +19,7 @@ class GarminClientService:
if self.username != username or self.password != password: if self.username != username or self.password != password:
self.username = username self.username = username
self.password = password self.password = password
self.client = None # Invalidate existing client if credentials change self.client = None # Invalidate existing client if credentials change
def authenticate(self) -> bool: def authenticate(self) -> bool:
"""Authenticates with Garmin Connect using stored credentials, or reuses existing client.""" """Authenticates with Garmin Connect using stored credentials, or reuses existing client."""
@@ -30,25 +31,37 @@ class GarminClientService:
try: try:
self.client = Garmin(self.username, self.password) self.client = Garmin(self.username, self.password)
self.client.login() self.client.login()
logger.info(f"Successfully authenticated Garmin client for {self.username}.") logger.info(
f"Successfully authenticated Garmin client for {self.username}."
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to authenticate Garmin client for {self.username}: {e}") logger.error(
f"Failed to authenticate Garmin client for {self.username}: {e}"
)
self.client = None self.client = None
return False return False
else: else:
# If client exists, assume it's authenticated and check session validity # If client exists, assume it's authenticated and check session validity
if self.check_session_validity(): if self.check_session_validity():
logger.debug(f"Garmin client already authenticated for {self.username}. Reusing existing client.") logger.debug(
f"Garmin client already authenticated for {self.username}. Reusing existing client."
)
return True return True
else: else:
logger.info(f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login.") logger.info(
f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login."
)
try: try:
self.client.login() # Attempt to re-login with existing client self.client.login() # Attempt to re-login with existing client
logger.info(f"Successfully re-logged in Garmin client for {self.username}.") logger.info(
f"Successfully re-logged in Garmin client for {self.username}."
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to re-login Garmin client for {self.username}: {e}") logger.error(
f"Failed to re-login Garmin client for {self.username}: {e}"
)
self.client = None self.client = None
return False return False
@@ -59,24 +72,27 @@ class GarminClientService:
def get_client(self) -> Garmin: def get_client(self) -> Garmin:
"""Returns the authenticated Garmin client instance.""" """Returns the authenticated Garmin client instance."""
if self.client is None: # Check self.client directly if self.client is None: # Check self.client directly
raise Exception("Garmin client not initialized or authenticated. Call authenticate first.") raise Exception(
"Garmin client not initialized or authenticated. Call authenticate first."
)
return self.client return self.client
def check_session_validity(self) -> bool: def check_session_validity(self) -> bool:
""" """
Checks if the current Garmin session is still valid by making a lightweight API call. Checks if the current Garmin session is still valid by making a lightweight API call.
""" """
if self.client is None: # Check self.client directly if self.client is None: # Check self.client directly
return False return False
try: try:
self.client.get_user_summary(datetime.now().isoformat().split('T')[0]) self.client.get_user_summary(datetime.now().isoformat().split("T")[0])
logger.debug(f"Garmin session is still valid for {self.username}.") logger.debug(f"Garmin session is still valid for {self.username}.")
return True return True
except Exception as e: except Exception as e:
logger.warning(f"Garmin session became invalid for {self.username}: {e}") logger.warning(f"Garmin session became invalid for {self.username}: {e}")
self.client = None # Invalidate client on session failure self.client = None # Invalidate client on session failure
return False return False
# Global instance for dependency injection # Global instance for dependency injection
garmin_client_service = GarminClientService() garmin_client_service = GarminClientService()

View File

@@ -3,9 +3,13 @@ import logging
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from ..jobs import job_store
from ..services.central_db_service import CentralDBService from ..services.central_db_service import CentralDBService
from ..services.garmin_auth_service import GarminAuthService from ..services.garmin_auth_service import GarminAuthService
from ..services.garmin_client_service import GarminClientService from ..services.garmin_client_service import GarminClientService
@@ -16,22 +20,25 @@ logger = logging.getLogger(__name__)
GARMIN_RETRY_STRATEGY = retry( GARMIN_RETRY_STRATEGY = retry(
stop=stop_after_attempt(5), stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=4, max=10), wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
reraise=True reraise=True,
) )
class GarminHealthService: class GarminHealthService:
def __init__( def __init__(
self, self,
garmin_client_service: GarminClientService, garmin_client_service: GarminClientService,
central_db_service: CentralDBService, central_db_service: CentralDBService,
garmin_auth_service: GarminAuthService garmin_auth_service: GarminAuthService,
): ):
self.garmin_client_service = garmin_client_service self.garmin_client_service = garmin_client_service
self.central_db_service = central_db_service self.central_db_service = central_db_service
self.garmin_auth_service = garmin_auth_service self.garmin_auth_service = garmin_auth_service
async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]: async def _get_authenticated_garmin_client(
self, user_id: int
) -> Optional[GarminClientService]:
credentials = await self.central_db_service.get_garmin_credentials(user_id) credentials = await self.central_db_service.get_garmin_credentials(user_id)
if not credentials: if not credentials:
logger.error(f"No Garmin credentials found for user {user_id}.") logger.error(f"No Garmin credentials found for user {user_id}.")
@@ -46,18 +53,29 @@ class GarminHealthService:
# Check if the client is authenticated after updating credentials # Check if the client is authenticated after updating credentials
if not self.garmin_client_service.is_authenticated(): if not self.garmin_client_service.is_authenticated():
if not self.garmin_client_service.authenticate(): if not self.garmin_client_service.authenticate():
logger.error(f"Failed to authenticate Garmin client for user {user_id}.") logger.error(
f"Failed to authenticate Garmin client for user {user_id}."
)
return None return None
return self.garmin_client_service return self.garmin_client_service
@GARMIN_RETRY_STRATEGY @GARMIN_RETRY_STRATEGY
async def download_and_save_health_metric( async def download_and_save_health_metric(
self, metric_data: Dict[str, Any], self,
garmin_client: Optional[GarminClientService] = None # New argument metric_data: Dict[str, Any],
garmin_client: Optional[GarminClientService] = None, # New argument
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
_garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id) # user_id is not directly available here, assuming it's handled by the caller or context
# For now, we'll assume the garmin_client is already authenticated for the correct user
# _garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id)
_garmin_client = (
garmin_client # Assuming garmin_client is passed and authenticated
)
if not _garmin_client: if not _garmin_client:
logger.error(
"Garmin client not provided or authenticated for health metric download."
)
return None return None
try: try:
@@ -66,14 +84,18 @@ class GarminHealthService:
value = metric_data.get("value") value = metric_data.get("value")
if not all([metric_type, timestamp, value]): if not all([metric_type, timestamp, value]):
logger.warning(f"Skipping health metric due to missing data: {metric_data}") logger.warning(
f"Skipping health metric due to missing data: {metric_data}"
)
return None return None
if isinstance(timestamp, str): if isinstance(timestamp, str):
try: try:
timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError: except ValueError:
logger.error(f"Invalid timestamp format for health metric: {timestamp}") logger.error(
f"Invalid timestamp format for health metric: {timestamp}"
)
return None return None
metric_data["timestamp"] = timestamp metric_data["timestamp"] = timestamp
@@ -85,56 +107,71 @@ class GarminHealthService:
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error downloading and saving health metric {metric_data}: {e}", f"Error downloading and saving health metric {metric_data}: {e}",
exc_info=True exc_info=True,
) )
return None return None
async def sync_health_metrics_in_background( async def sync_health_metrics_in_background(
self, self,
job_id: str, user_id: int,
current_sync_job_manager,
start_date: Optional[date] = None, start_date: Optional[date] = None,
end_date: Optional[date] = None, end_date: Optional[date] = None,
): ):
user_id = 1 # Assuming single user for now
try: try:
job_store.update_job(job_id, status="in_progress", progress=0.0) # user_id = 1 # Assuming single user for now - now passed as argument
# Authenticate Garmin client once at the beginning # Authenticate Garmin client once at the beginning
garmin_client = await self._get_authenticated_garmin_client(user_id) garmin_client = await self._get_authenticated_garmin_client(user_id)
if not garmin_client: if not garmin_client:
raise Exception("Garmin client not authenticated or failed to get valid credentials.") raise Exception(
"Garmin client not authenticated or failed to get valid credentials."
)
_start_date = start_date or date(2000, 1, 1) _start_date = start_date or date(2000, 1, 1)
_end_date = end_date or date.today() _end_date = end_date or date.today()
date_range = [_start_date + timedelta(days=x) for x in range((_end_date - _start_date).days + 1)] date_range = [
_start_date + timedelta(days=x)
for x in range((_end_date - _start_date).days + 1)
]
summary_tasks = [ summary_tasks = [
GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(d.isoformat()) GARMIN_RETRY_STRATEGY(garmin_client.get_client().get_daily_summary)(
d.isoformat()
)
for d in date_range for d in date_range
] ]
daily_summaries = await asyncio.gather(*summary_tasks, return_exceptions=True) daily_summaries = await asyncio.gather(
*summary_tasks, return_exceptions=True
)
all_metrics_data = [] all_metrics_data = []
for i, summary in enumerate(daily_summaries): for i, summary in enumerate(daily_summaries):
if isinstance(summary, Exception): if isinstance(summary, Exception):
logger.warning(f"Could not fetch daily summary for {date_range[i]}: {summary}") logger.warning(
f"Could not fetch daily summary for {date_range[i]}: {summary}"
)
continue continue
if summary: if summary:
if "heartRate" in summary: if "heartRate" in summary:
all_metrics_data.append({ all_metrics_data.append(
"type": "heart_rate", {
"timestamp": summary["calendarDate"], "type": "heart_rate",
"value": summary["heartRate"].get("restingHeartRate"), "timestamp": summary["calendarDate"],
"unit": "bpm" "value": summary["heartRate"].get("restingHeartRate"),
}) "unit": "bpm",
}
)
if "stress" in summary: if "stress" in summary:
all_metrics_data.append({ all_metrics_data.append(
"type": "stress_score", {
"timestamp": summary["calendarDate"], "type": "stress_score",
"value": summary["stress"].get("overallStressLevel"), "timestamp": summary["calendarDate"],
"unit": "score" "value": summary["stress"].get("overallStressLevel"),
}) "unit": "score",
}
)
total_metrics = len(all_metrics_data) total_metrics = len(all_metrics_data)
synced_count = 0 synced_count = 0
@@ -142,7 +179,7 @@ class GarminHealthService:
metric_save_tasks = [ metric_save_tasks = [
self.download_and_save_health_metric( self.download_and_save_health_metric(
metric_data=metric, metric_data=metric,
garmin_client=garmin_client # Pass the authenticated client garmin_client=garmin_client, # Pass the authenticated client
) )
for metric in all_metrics_data for metric in all_metrics_data
] ]
@@ -150,24 +187,19 @@ class GarminHealthService:
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"Failed to save health metric {all_metrics_data[i]}: {result}") logger.error(
f"Failed to save health metric {all_metrics_data[i]}: {result}"
)
else: else:
synced_count += 1 synced_count += 1
job_store.update_job(job_id, status="in_progress", progress=(i + 1) / total_metrics) await current_sync_job_manager.update_progress(
progress=(i + 1) / total_metrics
)
job_store.update_job( await current_sync_job_manager.complete_sync()
job_id,
status="completed",
progress=1.0,
details={
"synced_health_metrics_count": synced_count,
"total_health_metrics_found": total_metrics
}
)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error during health metrics synchronization for job {job_id}: {e}", f"Error during health metrics synchronization: {e}", exc_info=True
exc_info=True
) )
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e)) await current_sync_job_manager.fail_sync(error_message=str(e))

View File

@@ -2,9 +2,13 @@ import logging
import uuid import uuid
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from ..jobs import job_store
from ..services.garmin_client_service import GarminClientService from ..services.garmin_client_service import GarminClientService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,10 +17,11 @@ logger = logging.getLogger(__name__)
GARMIN_RETRY_STRATEGY = retry( GARMIN_RETRY_STRATEGY = retry(
stop=stop_after_attempt(5), stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=4, max=10), wait=wait_exponential(multiplier=1, min=4, max=10),
retry=retry_if_exception_type(Exception), # Broad exception for now, refine later retry=retry_if_exception_type(Exception), # Broad exception for now, refine later
reraise=True reraise=True,
) )
class GarminWorkoutService: class GarminWorkoutService:
def __init__(self, garmin_client_service: GarminClientService): def __init__(self, garmin_client_service: GarminClientService):
self.garmin_client_service = garmin_client_service self.garmin_client_service = garmin_client_service
@@ -27,8 +32,11 @@ class GarminWorkoutService:
# Get workout from CentralDB # Get workout from CentralDB
from ..config import settings from ..config import settings
from .central_db_service import CentralDBService from .central_db_service import CentralDBService
central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL) central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL)
workout = await central_db.get_workout_by_id(workout_id) # Assuming this method exists workout = await central_db.get_workout_by_id(
workout_id
) # Assuming this method exists
if not workout: if not workout:
logger.error(f"Workout with ID {workout_id} not found in CentralDB.") logger.error(f"Workout with ID {workout_id} not found in CentralDB.")
@@ -42,7 +50,7 @@ class GarminWorkoutService:
f"Simulating upload of workout {workout.name} (ID: {workout_id}) " f"Simulating upload of workout {workout.name} (ID: {workout_id}) "
"to Garmin Connect." "to Garmin Connect."
) )
garmin_workout_id = f"GARMIN_WORKOUT_{workout_id}" # Mock ID garmin_workout_id = f"GARMIN_WORKOUT_{workout_id}" # Mock ID
# Here we would update the workout in CentralDB with the garmin_workout_id # Here we would update the workout in CentralDB with the garmin_workout_id
# await central_db.update_workout( # await central_db.update_workout(
@@ -68,34 +76,21 @@ class GarminWorkoutService:
async def upload_workout_in_background( async def upload_workout_in_background(
self, self,
job_id: str, user_id: int,
current_sync_job_manager,
workout_id: uuid.UUID, workout_id: uuid.UUID,
): ):
try: try:
job_store.update_job(job_id, status="in_progress", progress=0.0)
uploaded_workout = await self.upload_workout(workout_id) uploaded_workout = await self.upload_workout(workout_id)
if uploaded_workout: if uploaded_workout:
job_store.update_job( await current_sync_job_manager.complete_sync()
job_id,
status="completed",
progress=1.0,
details={
"uploaded_workout_id": str(uploaded_workout.id),
"garmin_workout_id": uploaded_workout.garmin_workout_id
}
)
else: else:
job_store.update_job( await current_sync_job_manager.fail_sync(
job_id,
status="failed",
progress=1.0,
error_message=f"Failed to upload workout {workout_id}" error_message=f"Failed to upload workout {workout_id}"
) )
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error during workout upload: {e}", exc_info=True)
f"Error during workout upload for job {job_id}: {e}", exc_info=True await current_sync_job_manager.fail_sync(error_message=str(e))
)
job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e))

View File

@@ -19,5 +19,5 @@ class RateLimiter:
if not self.limiter.test(self.rate_limit_item, client_id): if not self.limiter.test(self.rate_limit_item, client_id):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later." detail="Rate limit exceeded. Please try again later.",
) )

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Optional
from ..models.sync_job import SyncJob, SyncJobType
class CurrentSyncJobManager:
_instance = None
_lock = asyncio.Lock()
_current_job: Optional[SyncJob] = None
def __new__(cls) -> CurrentSyncJobManager:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def start_sync(self, job_type: SyncJobType) -> SyncJob:
async with self._lock:
if self._current_job and self._current_job.status == "in_progress":
raise RuntimeError("A sync job is already in progress.")
self._current_job = SyncJob(
job_type=job_type,
status="in_progress",
start_time=datetime.now(),
)
return self._current_job
async def update_progress(self, progress: float) -> None:
async with self._lock:
if self._current_job:
self._current_job.progress = progress
async def complete_sync(self) -> None:
async with self._lock:
if self._current_job:
self._current_job.status = "completed"
self._current_job.end_time = datetime.now()
self._current_job.progress = 1.0
async def fail_sync(self, error_message: str) -> None:
async with self._lock:
if self._current_job:
self._current_job.status = "failed"
self._current_job.end_time = datetime.now()
self._current_job.error_message = error_message
async def get_current_sync_status(self) -> Optional[SyncJob]:
async with self._lock:
return self._current_job
async def is_sync_active(self) -> bool:
async with self._lock:
return self._current_job and self._current_job.status == "in_progress"
current_sync_job_manager = CurrentSyncJobManager()

View File

@@ -1,28 +0,0 @@
import logging
from typing import List, Optional
from uuid import UUID
from ..jobs import JobStore, SyncJob
logger = logging.getLogger(__name__)
class SyncStatusService:
def __init__(self, job_store: JobStore):
self.job_store = job_store
def get_sync_jobs(
self,
job_id: Optional[UUID] = None,
limit: int = 10,
offset: int = 0,
) -> 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 []

View File

@@ -5,7 +5,7 @@ from pathlib import Path
def calculate_sha256(file_path: Path) -> str: def calculate_sha256(file_path: Path) -> str:
"""Calculate the SHA256 checksum of a file.""" """Calculate the SHA256 checksum of a file."""
hasher = hashlib.sha256() hasher = hashlib.sha256()
with open(file_path, 'rb') as f: with open(file_path, "rb") as f:
while True: while True:
chunk = f.read(8192) # Read in 8KB chunks chunk = f.read(8192) # Read in 8KB chunks
if not chunk: if not chunk:

View File

@@ -2,25 +2,30 @@ from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from backend.src.main import app from fastapi.testclient import TestClient
from backend.src.schemas import GarminCredentials
from httpx import AsyncClient from src.main import app
from src.schemas import GarminCredentials
@pytest.fixture @pytest.fixture
def mock_garmin_auth_service(): def mock_garmin_auth_service():
with patch('backend.src.api.garmin_auth.GarminAuthService') as MockGarminAuthService: with patch("src.api.garmin_auth.GarminAuthService") as MockGarminAuthService:
service_instance = MockGarminAuthService.return_value service_instance = MockGarminAuthService.return_value
yield service_instance yield service_instance
@pytest.fixture @pytest.fixture
def mock_central_db_service(): def mock_central_db_service():
with patch('backend.src.api.garmin_auth.CentralDBService') as MockCentralDBService: with patch("src.api.garmin_auth.CentralDBService") as MockCentralDBService:
service_instance = MockCentralDBService.return_value service_instance = MockCentralDBService.return_value
yield service_instance yield service_instance
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_login_success_new_credentials(mock_garmin_auth_service, mock_central_db_service): async def test_garmin_login_success_new_credentials(
mock_garmin_auth_service, mock_central_db_service
):
username = "test@example.com" username = "test@example.com"
password = "password123" password = "password123"
@@ -29,28 +34,33 @@ async def test_garmin_login_success_new_credentials(mock_garmin_auth_service, mo
garmin_password_plaintext=password, garmin_password_plaintext=password,
access_token="mock_access_token", access_token="mock_access_token",
access_token_secret="mock_access_token_secret", access_token_secret="mock_access_token_secret",
token_expiration_date=datetime.utcnow() + timedelta(hours=1) token_expiration_date=datetime.utcnow() + timedelta(hours=1),
) )
mock_central_db_service.get_garmin_credentials.return_value = None # No existing credentials mock_central_db_service.get_garmin_credentials.return_value = (
mock_central_db_service.create_garmin_credentials.return_value = AsyncMock() # Simulate successful creation None # No existing credentials
)
mock_central_db_service.create_garmin_credentials.return_value = (
AsyncMock()
) # Simulate successful creation
async with AsyncClient(app=app, base_url="http://test") as client: with TestClient(app=app) as client:
response = await client.post( response = client.post(
"/api/garmin/login", "/api/garmin/login", json={"username": username, "password": password}
json={
"username": username,
"password": password
}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"message": "Garmin account linked successfully."} assert response.json() == {"message": "Garmin account linked successfully."}
mock_garmin_auth_service.initial_login.assert_called_once_with(username, password) mock_garmin_auth_service.initial_login.assert_called_once_with(username, password)
mock_central_db_service.get_garmin_credentials.assert_called_once_with(1) # Assuming user_id 1 mock_central_db_service.get_garmin_credentials.assert_called_once_with(
1
) # Assuming user_id 1
mock_central_db_service.create_garmin_credentials.assert_called_once() mock_central_db_service.create_garmin_credentials.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_login_success_update_credentials(mock_garmin_auth_service, mock_central_db_service): async def test_garmin_login_success_update_credentials(
mock_garmin_auth_service, mock_central_db_service
):
username = "test@example.com" username = "test@example.com"
password = "password123" password = "password123"
@@ -59,24 +69,22 @@ async def test_garmin_login_success_update_credentials(mock_garmin_auth_service,
garmin_password_plaintext=password, garmin_password_plaintext=password,
access_token="mock_access_token_new", access_token="mock_access_token_new",
access_token_secret="mock_access_token_secret_new", access_token_secret="mock_access_token_secret_new",
token_expiration_date=datetime.utcnow() + timedelta(hours=1) token_expiration_date=datetime.utcnow() + timedelta(hours=1),
) )
mock_central_db_service.get_garmin_credentials.return_value = GarminCredentials( mock_central_db_service.get_garmin_credentials.return_value = GarminCredentials(
garmin_username=username, garmin_username=username,
garmin_password_plaintext="old_password", garmin_password_plaintext="old_password",
access_token="old_access_token", access_token="old_access_token",
access_token_secret="old_access_token_secret", access_token_secret="old_access_token_secret",
token_expiration_date=datetime.utcnow() - timedelta(hours=1) token_expiration_date=datetime.utcnow() - timedelta(hours=1),
) # Existing credentials ) # Existing credentials
mock_central_db_service.update_garmin_credentials.return_value = AsyncMock() # Simulate successful update mock_central_db_service.update_garmin_credentials.return_value = (
AsyncMock()
) # Simulate successful update
async with AsyncClient(app=app, base_url="http://test") as client: with TestClient(app=app) as client:
response = await client.post( response = client.post(
"/api/garmin/login", "/api/garmin/login", json={"username": username, "password": password}
json={
"username": username,
"password": password
}
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -85,20 +93,21 @@ async def test_garmin_login_success_update_credentials(mock_garmin_auth_service,
mock_central_db_service.get_garmin_credentials.assert_called_once_with(1) mock_central_db_service.get_garmin_credentials.assert_called_once_with(1)
mock_central_db_service.update_garmin_credentials.assert_called_once() mock_central_db_service.update_garmin_credentials.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_login_failure_invalid_credentials(mock_garmin_auth_service, mock_central_db_service): async def test_garmin_login_failure_invalid_credentials(
mock_garmin_auth_service, mock_central_db_service
):
username = "invalid@example.com" username = "invalid@example.com"
password = "wrongpassword" password = "wrongpassword"
mock_garmin_auth_service.initial_login.return_value = None # Simulate failed Garmin login mock_garmin_auth_service.initial_login.return_value = (
None # Simulate failed Garmin login
)
async with AsyncClient(app=app, base_url="http://test") as client: with TestClient(app=app) as client:
response = await client.post( response = client.post(
"/api/garmin/login", "/api/garmin/login", json={"username": username, "password": password}
json={
"username": username,
"password": password
}
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -108,8 +117,11 @@ async def test_garmin_login_failure_invalid_credentials(mock_garmin_auth_service
mock_central_db_service.create_garmin_credentials.assert_not_called() mock_central_db_service.create_garmin_credentials.assert_not_called()
mock_central_db_service.update_garmin_credentials.assert_not_called() mock_central_db_service.update_garmin_credentials.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_login_failure_central_db_create_error(mock_garmin_auth_service, mock_central_db_service): async def test_garmin_login_failure_central_db_create_error(
mock_garmin_auth_service, mock_central_db_service
):
username = "test@example.com" username = "test@example.com"
password = "password123" password = "password123"
@@ -118,25 +130,28 @@ async def test_garmin_login_failure_central_db_create_error(mock_garmin_auth_ser
garmin_password_plaintext=password, garmin_password_plaintext=password,
access_token="mock_access_token", access_token="mock_access_token",
access_token_secret="mock_access_token_secret", access_token_secret="mock_access_token_secret",
token_expiration_date=datetime.utcnow() + timedelta(hours=1) token_expiration_date=datetime.utcnow() + timedelta(hours=1),
) )
mock_central_db_service.get_garmin_credentials.return_value = None mock_central_db_service.get_garmin_credentials.return_value = None
mock_central_db_service.create_garmin_credentials.return_value = None # Simulate CentralDB create failure mock_central_db_service.create_garmin_credentials.return_value = (
None # Simulate CentralDB create failure
)
async with AsyncClient(app=app, base_url="http://test") as client: with TestClient(app=app) as client:
response = await client.post( response = client.post(
"/api/garmin/login", "/api/garmin/login", json={"username": username, "password": password}
json={
"username": username,
"password": password
}
) )
assert response.status_code == 500 assert response.status_code == 500
assert response.json() == {"detail": "Failed to store Garmin credentials in CentralDB."} assert response.json() == {
"detail": "Failed to store Garmin credentials in CentralDB."
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_login_failure_central_db_update_error(mock_garmin_auth_service, mock_central_db_service): async def test_garmin_login_failure_central_db_update_error(
mock_garmin_auth_service, mock_central_db_service
):
username = "test@example.com" username = "test@example.com"
password = "password123" password = "password123"
@@ -145,25 +160,25 @@ async def test_garmin_login_failure_central_db_update_error(mock_garmin_auth_ser
garmin_password_plaintext=password, garmin_password_plaintext=password,
access_token="mock_access_token_new", access_token="mock_access_token_new",
access_token_secret="mock_access_token_secret_new", access_token_secret="mock_access_token_secret_new",
token_expiration_date=datetime.utcnow() + timedelta(hours=1) token_expiration_date=datetime.utcnow() + timedelta(hours=1),
) )
mock_central_db_service.get_garmin_credentials.return_value = GarminCredentials( mock_central_db_service.get_garmin_credentials.return_value = GarminCredentials(
garmin_username=username, garmin_username=username,
garmin_password_plaintext="old_password", garmin_password_plaintext="old_password",
access_token="old_access_token", access_token="old_access_token",
access_token_secret="old_access_token_secret", access_token_secret="old_access_token_secret",
token_expiration_date=datetime.utcnow() - timedelta(hours=1) token_expiration_date=datetime.utcnow() - timedelta(hours=1),
)
mock_central_db_service.update_garmin_credentials.return_value = (
None # Simulate CentralDB update failure
) )
mock_central_db_service.update_garmin_credentials.return_value = None # Simulate CentralDB update failure
async with AsyncClient(app=app, base_url="http://test") as client: with TestClient(app=app) as client:
response = await client.post( response = client.post(
"/api/garmin/login", "/api/garmin/login", json={"username": username, "password": password}
json={
"username": username,
"password": password
}
) )
assert response.status_code == 500 assert response.status_code == 500
assert response.json() == {"detail": "Failed to update Garmin credentials in CentralDB."} assert response.json() == {
"detail": "Failed to update Garmin credentials in CentralDB."
}

View File

@@ -1,80 +1,88 @@
from datetime import date
from unittest.mock import AsyncMock, patch
import pytest
from backend.src.main import app from backend.src.main import app
from backend.src.schemas import User from backend.src.services.sync_manager import current_sync_job_manager
from fastapi import HTTPException from fastapi.testclient import TestClient
from httpx import AsyncClient
client = TestClient(app)
@pytest.fixture def test_get_sync_status():
def mock_garmin_activity_service(): response = client.get("/api/sync/garmin/sync/status")
with patch('backend.src.api.garmin_sync.GarminActivityService') as MockGarminActivityService: assert response.status_code == 200
service_instance = MockGarminActivityService.return_value
yield service_instance
@pytest.fixture
def mock_get_current_user():
with patch('backend.src.api.garmin_sync.get_current_user') as mock_current_user:
mock_current_user.return_value = User(id=1, name="Test User", email="test@example.com")
yield mock_current_user
@pytest.mark.asyncio
async def test_trigger_garmin_activity_sync_success(mock_garmin_activity_service, mock_get_current_user):
mock_garmin_activity_service.sync_activities_in_background = AsyncMock()
mock_garmin_activity_service.sync_activities_in_background.return_value = None
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.post(
"/api/sync/garmin/activities",
json={
"force_resync": False,
"start_date": "2023-01-01",
"end_date": "2023-01-31"
}
)
def test_trigger_activity_sync_success():
response = client.post("/api/sync/garmin/activities", json={})
assert response.status_code == 202 assert response.status_code == 202
response_json = response.json() assert response.json() == {
assert "job_id" in response_json "message": "Activity synchronization initiated successfully."
assert "status" in response_json }
assert response_json["status"] == "pending"
mock_garmin_activity_service.sync_activities_in_background.assert_called_once()
args, kwargs = mock_garmin_activity_service.sync_activities_in_background.call_args
assert not args[1] # force_resync
assert args[2] == date(2023, 1, 1) # start_date
assert args[3] == date(2023, 1, 31) # end_date
@pytest.mark.asyncio
async def test_trigger_garmin_activity_sync_no_dates(mock_garmin_activity_service, mock_get_current_user):
mock_garmin_activity_service.sync_activities_in_background = AsyncMock()
mock_garmin_activity_service.sync_activities_in_background.return_value = None
async with AsyncClient(app=app, base_url="http://test") as client: def test_trigger_activity_sync_conflict():
response = await client.post( # Manually start a sync to simulate a conflict
"/api/sync/garmin/activities", current_sync_job_manager._current_job = current_sync_job_manager.start_sync(
json={} "activities"
) )
response = client.post("/api/sync/garmin/activities", json={})
assert response.status_code == 409
assert response.json() == {
"detail": "A synchronization is already in progress. Please wait or check status."
}
# Clean up
current_sync_job_manager._current_job = None
def test_trigger_workout_sync_success():
response = client.post(
"/api/sync/garmin/workouts",
json={"workout_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef"},
)
assert response.status_code == 202 assert response.status_code == 202
response_json = response.json() assert response.json() == {
assert "job_id" in response_json "message": "Workout synchronization initiated successfully."
assert "status" in response_json }
assert response_json["status"] == "pending"
mock_garmin_activity_service.sync_activities_in_background.assert_called_once()
args, kwargs = mock_garmin_activity_service.sync_activities_in_background.call_args
assert not args[1] # force_resync
assert args[2] is None # start_date
assert args[3] is None # end_date
@pytest.mark.asyncio
async def test_trigger_garmin_activity_sync_unauthorized(): def test_trigger_workout_sync_conflict():
with patch('backend.src.api.garmin_sync.get_current_user', side_effect=HTTPException(status_code=401)): # Manually start a sync to simulate a conflict
async with AsyncClient(app=app, base_url="http://test") as client: current_sync_job_manager._current_job = current_sync_job_manager.start_sync(
response = await client.post( "workouts"
"/api/sync/garmin/activities", )
json={}
) response = client.post(
assert response.status_code == 401 "/api/sync/garmin/workouts",
assert response.json() == {"detail": "Not Authenticated"} # Default FastAPI 401 detail json={"workout_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef"},
)
assert response.status_code == 409
assert response.json() == {
"detail": "A synchronization is already in progress. Please wait or check status."
}
# Clean up
current_sync_job_manager._current_job = None
def test_trigger_health_sync_success():
response = client.post("/api/sync/garmin/health", json={})
assert response.status_code == 202
assert response.json() == {
"message": "Health metrics synchronization initiated successfully."
}
def test_trigger_health_sync_conflict():
# Manually start a sync to simulate a conflict
current_sync_job_manager._current_job = current_sync_job_manager.start_sync(
"health"
)
response = client.post("/api/sync/garmin/health", json={})
assert response.status_code == 409
assert response.json() == {
"detail": "A synchronization is already in progress. Please wait or check status."
}
# Clean up
current_sync_job_manager._current_job = None

View File

@@ -1,40 +1,44 @@
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from backend.src.schemas import GarminCredentials
from backend.src.services.garmin_activity_service import GarminActivityService from src.schemas import GarminCredentials
from backend.src.services.garmin_health_service import GarminHealthService from src.services.garmin_activity_service import GarminActivityService
from src.services.garmin_health_service import GarminHealthService
@pytest.fixture @pytest.fixture
def mock_garmin_auth_service_instance(): def mock_garmin_auth_service_instance():
with patch( with patch(
'backend.src.services.garmin_activity_service.GarminAuthService' "src.services.garmin_activity_service.GarminAuthService"
) as MockGarminAuthService: ) as MockGarminAuthService:
instance = MockGarminAuthService.return_value instance = MockGarminAuthService.return_value
yield instance yield instance
@pytest.fixture @pytest.fixture
def mock_central_db_service_instance(): def mock_central_db_service_instance():
with patch( with patch(
'backend.src.services.garmin_activity_service.CentralDBService' "src.services.garmin_activity_service.CentralDBService"
) as MockCentralDBService: ) as MockCentralDBService:
service_instance = MockCentralDBService.return_value service_instance = MockCentralDBService.return_value
yield service_instance yield service_instance
@pytest.fixture @pytest.fixture
def mock_garmin_client_service_instance(): def mock_garmin_client_service_instance():
with patch( with patch(
'backend.src.services.garmin_activity_service.GarminClientService' "src.services.garmin_activity_service.GarminClientService"
) as MockGarminClientService: ) as MockGarminClientService:
instance = MockGarminClientService.return_value instance = MockGarminClientService.return_value
yield instance yield instance
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_activity_sync_authentication_flow( async def test_garmin_activity_sync_authentication_flow(
mock_garmin_auth_service_instance, mock_garmin_auth_service_instance,
mock_central_db_service_instance, mock_central_db_service_instance,
mock_garmin_client_service_instance mock_garmin_client_service_instance,
): ):
user_id = 1 user_id = 1
username = "test@example.com" username = "test@example.com"
@@ -42,10 +46,11 @@ async def test_garmin_activity_sync_authentication_flow(
# Mock GarminCredentials from CentralDB # Mock GarminCredentials from CentralDB
mock_credentials = GarminCredentials( mock_credentials = GarminCredentials(
garmin_username=username, garmin_username=username, garmin_password_plaintext=password
garmin_password_plaintext=password )
mock_central_db_service_instance.get_garmin_credentials.return_value = (
mock_credentials
) )
mock_central_db_service_instance.get_garmin_credentials.return_value = mock_credentials
# Mock GarminClientService authentication # Mock GarminClientService authentication
mock_garmin_client_service_instance.is_authenticated.return_value = False mock_garmin_client_service_instance.is_authenticated.return_value = False
@@ -53,31 +58,40 @@ async def test_garmin_activity_sync_authentication_flow(
# Mock GarminClientService.get_client().get_activities # Mock GarminClientService.get_client().get_activities
mock_garmin_client_instance = AsyncMock() mock_garmin_client_instance = AsyncMock()
mock_garmin_client_service_instance.get_client.return_value = mock_garmin_client_instance mock_garmin_client_service_instance.get_client.return_value = (
mock_garmin_client_instance.get_activities.return_value = [] # Simulate no activities mock_garmin_client_instance
)
mock_garmin_client_instance.get_activities.return_value = (
[]
) # Simulate no activities
activity_service = GarminActivityService( activity_service = GarminActivityService(
garmin_client_service=mock_garmin_client_service_instance, garmin_client_service=mock_garmin_client_service_instance,
activity_download_service=AsyncMock(), # Mock this dependency activity_download_service=AsyncMock(), # Mock this dependency
garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called
central_db_service=mock_central_db_service_instance central_db_service=mock_central_db_service_instance,
) )
# Call sync_activities_in_background, which will trigger authentication # Call sync_activities_in_background, which will trigger authentication
await activity_service.sync_activities_in_background(job_id="test_job") await activity_service.sync_activities_in_background(user_id=user_id)
# Assertions # Assertions
mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(user_id) mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(username, password) user_id
)
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(
username, password
)
mock_garmin_client_service_instance.is_authenticated.assert_called_once() mock_garmin_client_service_instance.is_authenticated.assert_called_once()
mock_garmin_client_service_instance.authenticate.assert_called_once() mock_garmin_client_service_instance.authenticate.assert_called_once()
mock_garmin_client_instance.get_activities.assert_called_once() mock_garmin_client_instance.get_activities.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_garmin_health_sync_authentication_flow( async def test_garmin_health_sync_authentication_flow(
mock_garmin_auth_service_instance, mock_garmin_auth_service_instance,
mock_central_db_service_instance, mock_central_db_service_instance,
mock_garmin_client_service_instance mock_garmin_client_service_instance,
): ):
user_id = 1 user_id = 1
username = "test@example.com" username = "test@example.com"
@@ -85,10 +99,11 @@ async def test_garmin_health_sync_authentication_flow(
# Mock GarminCredentials from CentralDB # Mock GarminCredentials from CentralDB
mock_credentials = GarminCredentials( mock_credentials = GarminCredentials(
garmin_username=username, garmin_username=username, garmin_password_plaintext=password
garmin_password_plaintext=password )
mock_central_db_service_instance.get_garmin_credentials.return_value = (
mock_credentials
) )
mock_central_db_service_instance.get_garmin_credentials.return_value = mock_credentials
# Mock GarminClientService authentication # Mock GarminClientService authentication
mock_garmin_client_service_instance.is_authenticated.return_value = False mock_garmin_client_service_instance.is_authenticated.return_value = False
@@ -96,21 +111,29 @@ async def test_garmin_health_sync_authentication_flow(
# Mock GarminClientService.get_client().get_daily_summary # Mock GarminClientService.get_client().get_daily_summary
mock_garmin_client_instance = AsyncMock() mock_garmin_client_instance = AsyncMock()
mock_garmin_client_service_instance.get_client.return_value = mock_garmin_client_instance mock_garmin_client_service_instance.get_client.return_value = (
mock_garmin_client_instance.get_daily_summary.return_value = [] # Simulate no summaries mock_garmin_client_instance
)
mock_garmin_client_instance.get_daily_summary.return_value = (
[]
) # Simulate no summaries
health_service = GarminHealthService( health_service = GarminHealthService(
garmin_client_service=mock_garmin_client_service_instance, garmin_client_service=mock_garmin_client_service_instance,
central_db_service=mock_central_db_service_instance, central_db_service=mock_central_db_service_instance,
garmin_auth_service=mock_garmin_auth_service_instance # Still needed for init, but methods not called garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called
) )
# Call sync_health_metrics_in_background, which will trigger authentication # Call sync_health_metrics_in_background, which will trigger authentication
await health_service.sync_health_metrics_in_background(job_id="test_job") await health_service.sync_health_metrics_in_background(user_id=user_id)
# Assertions # Assertions
mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(user_id) mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(username, password) user_id
)
mock_garmin_client_service_instance.update_credentials.assert_called_once_with(
username, password
)
mock_garmin_client_service_instance.is_authenticated.assert_called_once() mock_garmin_client_service_instance.is_authenticated.assert_called_once()
mock_garmin_client_service_instance.authenticate.assert_called_once() mock_garmin_client_service_instance.authenticate.assert_called_once()
mock_garmin_client_instance.get_daily_summary.assert_called_once() mock_garmin_client_instance.get_daily_summary.assert_called_once()

View File

@@ -10,7 +10,7 @@ from src.services.auth_service import AuthService
@pytest.fixture @pytest.fixture
def auth_service(): def auth_service():
"""Fixture for AuthService with mocked CentralDBService.""" """Fixture for AuthService with mocked CentralDBService."""
with patch('src.services.auth_service.CentralDBService') as MockCentralDBService: with patch("src.services.auth_service.CentralDBService") as MockCentralDBService:
mock_central_db_instance = MockCentralDBService.return_value mock_central_db_instance = MockCentralDBService.return_value
mock_central_db_instance.get_user_by_email = AsyncMock() mock_central_db_instance.get_user_by_email = AsyncMock()
mock_central_db_instance.create_user = AsyncMock() mock_central_db_instance.create_user = AsyncMock()
@@ -21,21 +21,24 @@ def auth_service():
service.central_db = mock_central_db_instance service.central_db = mock_central_db_instance
yield service yield service
@pytest.fixture @pytest.fixture
def mock_garth_login(): def mock_garth_login():
"""Fixture to mock garth.login.""" """Fixture to mock garth.login."""
with patch('garth.login') as mock_login: with patch("garth.login") as mock_login:
yield mock_login yield mock_login
@pytest.fixture @pytest.fixture
def mock_garth_client(): def mock_garth_client():
"""Fixture to mock garth.client attributes.""" """Fixture to mock garth.client attributes."""
with patch('garth.client') as mock_client: with patch("garth.client") as mock_client:
mock_client.oauth2_token = "mock_oauth2_token" mock_client.oauth2_token = "mock_oauth2_token"
mock_client.refresh_token = "mock_refresh_token" mock_client.refresh_token = "mock_refresh_token"
mock_client.token_expires_at = 1234567890 mock_client.token_expires_at = 1234567890
yield mock_client yield mock_client
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_garmin_connect_new_user_success( async def test_authenticate_garmin_connect_new_user_success(
auth_service, mock_garth_login, mock_garth_client auth_service, mock_garth_login, mock_garth_client
@@ -57,7 +60,11 @@ async def test_authenticate_garmin_connect_new_user_success(
auth_service.central_db.create_token.assert_called_once() auth_service.central_db.create_token.assert_called_once()
auth_service.central_db.update_token.assert_not_called() auth_service.central_db.update_token.assert_not_called()
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)} assert result == {
"message": "Garmin Connect authentication successful",
"user_id": str(mock_user.id),
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_garmin_connect_existing_user_success( async def test_authenticate_garmin_connect_existing_user_success(
@@ -79,7 +86,11 @@ async def test_authenticate_garmin_connect_existing_user_success(
auth_service.central_db.create_token.assert_called_once() auth_service.central_db.create_token.assert_called_once()
auth_service.central_db.update_token.assert_not_called() auth_service.central_db.update_token.assert_not_called()
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)} assert result == {
"message": "Garmin Connect authentication successful",
"user_id": str(mock_user.id),
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_garmin_connect_existing_user_existing_token_success( async def test_authenticate_garmin_connect_existing_user_existing_token_success(
@@ -89,9 +100,12 @@ async def test_authenticate_garmin_connect_existing_user_existing_token_success(
email = "existing_user_token@example.com" email = "existing_user_token@example.com"
password = "password123" password = "password123"
mock_user = User(id=uuid.uuid4(), name=email, email=email) mock_user = User(id=uuid.uuid4(), name=email, email=email)
mock_user_id = mock_user.id # Capture the generated UUID mock_user_id = mock_user.id # Capture the generated UUID
mock_existing_token = TokenCreate( mock_existing_token = TokenCreate(
access_token="old_access", refresh_token="old_refresh", expires_at=1111111111, user_id=mock_user_id access_token="old_access",
refresh_token="old_refresh",
expires_at=1111111111,
user_id=mock_user_id,
) )
auth_service.central_db.get_user_by_email.return_value = mock_user auth_service.central_db.get_user_by_email.return_value = mock_user
@@ -106,9 +120,16 @@ async def test_authenticate_garmin_connect_existing_user_existing_token_success(
auth_service.central_db.update_token.assert_called_once() auth_service.central_db.update_token.assert_called_once()
auth_service.central_db.create_token.assert_not_called() auth_service.central_db.create_token.assert_not_called()
assert result == {"message": "Garmin Connect authentication successful", "user_id": str(mock_user.id)} assert result == {
"message": "Garmin Connect authentication successful",
"user_id": str(mock_user.id),
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_garth_login): async def test_authenticate_garmin_connect_garmin_failure(
auth_service, mock_garth_login
):
"""Test Garmin authentication failure.""" """Test Garmin authentication failure."""
email = "fail_garmin@example.com" email = "fail_garmin@example.com"
password = "password123" password = "password123"
@@ -125,6 +146,7 @@ async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_gar
assert result is None assert result is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_authenticate_garmin_connect_central_db_user_creation_failure( async def test_authenticate_garmin_connect_central_db_user_creation_failure(
auth_service, mock_garth_login, mock_garth_client auth_service, mock_garth_login, mock_garth_client

View File

@@ -2,25 +2,33 @@ from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from backend.src.schemas import GarminCredentials
from backend.src.services.garmin_auth_service import GarminAuthService from src.schemas import GarminCredentials
from src.services.garmin_auth_service import GarminAuthService
@pytest.fixture @pytest.fixture
def garmin_auth_service(): def garmin_auth_service():
return GarminAuthService() return GarminAuthService()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_initial_login_success(garmin_auth_service): async def test_initial_login_success(garmin_auth_service):
username = "test@example.com" username = "test@example.com"
password = "password123" password = "password123"
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth: with patch("src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock() mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.login.return_value = None # garth.login doesn't return anything directly mock_garth.Client.return_value.login.return_value = (
None # garth.login doesn't return anything directly
)
# Mock the attributes that would be set on the client after login # Mock the attributes that would be set on the client after login
mock_garth.Client.return_value.access_token = f"mock_access_token_for_{username}" mock_garth.Client.return_value.access_token = (
mock_garth.Client.return_value.access_token_secret = f"mock_access_token_secret_for_{username}" f"mock_access_token_for_{username}"
)
mock_garth.Client.return_value.access_token_secret = (
f"mock_access_token_secret_for_{username}"
)
mock_garth.Client.return_value.expires_in = 300 mock_garth.Client.return_value.expires_in = 300
credentials = await garmin_auth_service.initial_login(username, password) credentials = await garmin_auth_service.initial_login(username, password)
@@ -33,19 +41,23 @@ async def test_initial_login_success(garmin_auth_service):
assert isinstance(credentials.token_expiration_date, datetime) assert isinstance(credentials.token_expiration_date, datetime)
assert credentials.token_expiration_date > datetime.utcnow() assert credentials.token_expiration_date > datetime.utcnow()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_initial_login_failure(garmin_auth_service): async def test_initial_login_failure(garmin_auth_service):
username = "invalid@example.com" username = "invalid@example.com"
password = "wrongpassword" password = "wrongpassword"
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth: with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock() mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.login.side_effect = Exception("Garmin login failed") mock_garth.Client.return_value.login.side_effect = Exception(
"Garmin login failed"
)
credentials = await garmin_auth_service.initial_login(username, password) credentials = await garmin_auth_service.initial_login(username, password)
assert credentials is None assert credentials is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refresh_tokens_success(garmin_auth_service): async def test_refresh_tokens_success(garmin_auth_service):
credentials = GarminCredentials( credentials = GarminCredentials(
@@ -53,14 +65,16 @@ async def test_refresh_tokens_success(garmin_auth_service):
garmin_password_plaintext="password123", garmin_password_plaintext="password123",
access_token="old_access_token", access_token="old_access_token",
access_token_secret="old_access_token_secret", access_token_secret="old_access_token_secret",
token_expiration_date=datetime.utcnow() - timedelta(minutes=1) # Expired token token_expiration_date=datetime.utcnow() - timedelta(minutes=1), # Expired token
) )
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth: with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock() mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.reauthorize.return_value = None mock_garth.Client.return_value.reauthorize.return_value = None
mock_garth.Client.return_value.access_token = "refreshed_access_token" mock_garth.Client.return_value.access_token = "refreshed_access_token"
mock_garth.Client.return_value.access_token_secret = "refreshed_access_token_secret" mock_garth.Client.return_value.access_token_secret = (
"refreshed_access_token_secret"
)
mock_garth.Client.return_value.expires_in = 300 mock_garth.Client.return_value.expires_in = 300
refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials) refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials)
@@ -68,10 +82,13 @@ async def test_refresh_tokens_success(garmin_auth_service):
assert refreshed_credentials is not None assert refreshed_credentials is not None
assert refreshed_credentials.garmin_username == credentials.garmin_username assert refreshed_credentials.garmin_username == credentials.garmin_username
assert refreshed_credentials.access_token == "refreshed_access_token" assert refreshed_credentials.access_token == "refreshed_access_token"
assert refreshed_credentials.access_token_secret == "refreshed_access_token_secret" assert (
refreshed_credentials.access_token_secret == "refreshed_access_token_secret"
)
assert isinstance(refreshed_credentials.token_expiration_date, datetime) assert isinstance(refreshed_credentials.token_expiration_date, datetime)
assert refreshed_credentials.token_expiration_date > datetime.utcnow() assert refreshed_credentials.token_expiration_date > datetime.utcnow()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refresh_tokens_failure(garmin_auth_service): async def test_refresh_tokens_failure(garmin_auth_service):
credentials = GarminCredentials( credentials = GarminCredentials(
@@ -79,12 +96,14 @@ async def test_refresh_tokens_failure(garmin_auth_service):
garmin_password_plaintext="invalid_password", garmin_password_plaintext="invalid_password",
access_token="old_access_token", access_token="old_access_token",
access_token_secret="old_access_token_secret", access_token_secret="old_access_token_secret",
token_expiration_date=datetime.utcnow() - timedelta(minutes=1) token_expiration_date=datetime.utcnow() - timedelta(minutes=1),
) )
with patch('backend.src.services.garmin_auth_service.garth') as mock_garth: with patch("backend.src.services.garmin_auth_service.garth") as mock_garth:
mock_garth.Client.return_value = AsyncMock() mock_garth.Client.return_value = AsyncMock()
mock_garth.Client.return_value.reauthorize.side_effect = Exception("Garmin reauthorize failed") mock_garth.Client.return_value.reauthorize.side_effect = Exception(
"Garmin reauthorize failed"
)
refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials) refreshed_credentials = await garmin_auth_service.refresh_tokens(credentials)

View File

@@ -18,6 +18,7 @@ async def test_rate_limiter_allows_requests_within_limit():
except HTTPException: except HTTPException:
pytest.fail("HTTPException raised unexpectedly.") pytest.fail("HTTPException raised unexpectedly.")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rate_limiter_raises_exception_when_exceeded(): async def test_rate_limiter_raises_exception_when_exceeded():
"""Test that the rate limiter raises an HTTPException when the rate limit is exceeded.""" """Test that the rate limiter raises an HTTPException when the rate limit is exceeded."""
@@ -25,15 +26,20 @@ async def test_rate_limiter_raises_exception_when_exceeded():
mock_request = MagicMock() mock_request = MagicMock()
# Mock the limiter.test method # Mock the limiter.test method
with patch.object(rate_limiter.limiter, 'test') as mock_limiter_test: with patch.object(rate_limiter.limiter, "test") as mock_limiter_test:
mock_limiter_test.side_effect = [True, False] # First call returns True, second returns False mock_limiter_test.side_effect = [
True,
False,
] # First call returns True, second returns False
await rate_limiter(mock_request) # First call, should pass await rate_limiter(mock_request) # First call, should pass
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await rate_limiter(mock_request) # Second call, should fail await rate_limiter(mock_request) # Second call, should fail
assert exc_info.value.status_code == 429 assert exc_info.value.status_code == 429
mock_limiter_test.assert_called_with(rate_limiter.rate_limit_item, "single_user_system") mock_limiter_test.assert_called_with(
rate_limiter.rate_limit_item, "single_user_system"
)
assert exc_info.value.status_code == 429 assert exc_info.value.status_code == 429

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from backend.src.models.sync_job import SyncJob
def test_sync_job_defaults():
job = SyncJob()
assert job.status == "pending"
assert job.progress == 0.0
assert job.start_time is None
assert job.end_time is None
assert job.error_message is None
assert job.job_type is None
def test_sync_job_with_values():
start_time = datetime.now()
job = SyncJob(
status="in_progress",
progress=0.5,
start_time=start_time,
job_type="activities",
)
assert job.status == "in_progress"
assert job.progress == 0.5
assert job.start_time == start_time
assert job.job_type == "activities"

View File

@@ -0,0 +1,46 @@
import pytest
from backend.src.services.sync_manager import CurrentSyncJobManager
@pytest.mark.asyncio
async def test_singleton():
manager1 = CurrentSyncJobManager()
manager2 = CurrentSyncJobManager()
assert manager1 is manager2
@pytest.mark.asyncio
async def test_start_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
status = await manager.get_current_sync_status()
assert status.status == "in_progress"
assert status.job_type == "activities"
@pytest.mark.asyncio
async def test_start_sync_while_active():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
with pytest.raises(RuntimeError):
await manager.start_sync("workouts")
@pytest.mark.asyncio
async def test_complete_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
await manager.complete_sync()
status = await manager.get_current_sync_status()
assert status.status == "completed"
assert status.progress == 1.0
@pytest.mark.asyncio
async def test_fail_sync():
manager = CurrentSyncJobManager()
await manager.start_sync("activities")
await manager.fail_sync("Test error")
status = await manager.get_current_sync_status()
assert status.status == "failed"
assert status.error_message == "Test error"

View File

@@ -1,36 +0,0 @@
import uuid
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from src.jobs import JobStore, SyncJob
from src.services.sync_status_service import SyncStatusService
@pytest.fixture
def mock_job_store():
"""Fixture to create a mock JobStore."""
job_store = MagicMock(spec=JobStore)
job_id = uuid.uuid4()
job = SyncJob(id=str(job_id), status="completed", created_at=datetime.utcnow())
job_store.get_all_jobs.return_value = [job]
job_store.get_job.return_value = job
return job_store
def test_get_sync_jobs_all(mock_job_store):
"""Test retrieving all sync jobs."""
service = SyncStatusService(job_store=mock_job_store)
jobs = service.get_sync_jobs()
assert len(jobs) == 1
mock_job_store.get_all_jobs.assert_called_once()
def test_get_sync_job_by_id(mock_job_store):
"""Test retrieving a single sync job by ID."""
service = SyncStatusService(job_store=mock_job_store)
job_id = mock_job_store.get_job.return_value.id
# The get_sync_jobs implementation filters all jobs, so we need to mock get_all_jobs
mock_job_store.get_all_jobs.return_value = [mock_job_store.get_job.return_value]
jobs = service.get_sync_jobs(job_id=job_id)
assert len(jobs) == 1
assert jobs[0].id == str(job_id)

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Simplify Sync Job Management with Progress Tracking
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: Saturday, October 11, 2025
**Feature**: [Link to spec.md]
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,207 @@
{
"openapi": "3.0.0",
"info": {
"title": "Garmin Sync API with Progress Tracking",
"version": "1.0.0",
"description": "API for initiating and tracking the status of Garmin data synchronization for a single user."
},
"servers": [
{
"url": "/api/v1"
}
],
"paths": {
"/garmin/activities": {
"post": {
"summary": "Initiate Garmin Activity Synchronization",
"operationId": "syncGarminActivities",
"tags": ["Garmin Sync"],
"responses": {
"200": {
"description": "Activity synchronization initiated successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Activity synchronization initiated successfully."
}
}
}
}
}
},
"409": {
"description": "Conflict: A sync is already in progress.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"example": "A synchronization is already in progress. Please wait or check status."
}
}
}
}
}
}
}
}
},
"/garmin/workouts": {
"post": {
"summary": "Initiate Garmin Workout Synchronization",
"operationId": "syncGarminWorkouts",
"tags": ["Garmin Sync"],
"responses": {
"200": {
"description": "Workout synchronization initiated successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Workout synchronization initiated successfully."
}
}
}
}
}
},
"409": {
"description": "Conflict: A sync is already in progress.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"example": "A synchronization is already in progress. Please wait or check status."
}
}
}
}
}
}
}
}
},
"/garmin/health": {
"post": {
"summary": "Initiate Garmin Health Metrics Synchronization",
"operationId": "syncGarminHealth",
"tags": ["Garmin Sync"],
"responses": {
"200": {
"description": "Health metrics synchronization initiated successfully.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"example": "Health metrics synchronization initiated successfully."
}
}
}
}
}
},
"409": {
"description": "Conflict: A sync is already in progress.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"detail": {
"type": "string",
"example": "A synchronization is already in progress. Please wait or check status."
}
}
}
}
}
}
}
}
},
"/garmin/sync/status": {
"get": {
"summary": "Get Current Garmin Sync Status",
"operationId": "getGarminSyncStatus",
"tags": ["Garmin Sync"],
"responses": {
"200": {
"description": "Current status of the Garmin synchronization job.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SyncJob"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"SyncJob": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Current state of the sync.",
"enum": ["idle", "in_progress", "completed", "failed"],
"example": "in_progress"
},
"progress": {
"type": "number",
"format": "float",
"description": "Completion percentage (0.0 to 1.0).",
"minimum": 0.0,
"maximum": 1.0,
"example": 0.5
},
"start_time": {
"type": "string",
"format": "date-time",
"description": "Timestamp when the sync operation began.",
"example": "2025-10-11T10:00:00Z"
},
"end_time": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "Timestamp when the sync operation concluded (either completed or failed).",
"example": "2025-10-11T10:15:00Z"
},
"error_message": {
"type": "string",
"nullable": true,
"description": "Details if the sync operation failed.",
"example": "Failed to connect to Garmin API."
},
"job_type": {
"type": "string",
"nullable": true,
"description": "Type of data being synchronized.",
"enum": ["activities", "health", "workouts"],
"example": "activities"
}
},
"required": ["status", "progress", "start_time"]
}
}
}
}

View File

@@ -0,0 +1,14 @@
# Data Model: Sync Job Management with Progress Tracking
## Entity: SyncJob
Represents the state and progress of the single active synchronization process.
### Attributes:
* `status` (string): Indicates the current state of the sync. Possible values: "idle", "in_progress", "completed", "failed".
* `progress` (float): Completion percentage, ranging from 0.0 to 1.0.
* `start_time` (datetime): Timestamp when the sync operation began.
* `end_time` (datetime, optional): Timestamp when the sync operation concluded (either completed or failed).
* `error_message` (string, optional): Contains details if the sync operation failed.
* `job_type` (string, optional): Indicates the type of data being synchronized. Possible values: "activities", "health", "workouts".

View File

@@ -0,0 +1,65 @@
# Implementation Plan: Simplify Sync Job Management with Progress Tracking
**Branch**: `004-home-sstent-projects` | **Date**: Saturday, October 11, 2025 | **Spec**: /home/sstent/Projects/FitTrack_GarminSync/specs/004-home-sstent-projects/spec.md
**Input**: Feature specification from `/specs/004-home-sstent-projects/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
## Summary
The feature aims to simplify sync job management for a single-user system by allowing only one sync job at a time and providing progress tracking via a polling API. This involves reintroducing a simplified `SyncJob` model and a `CurrentSyncJobManager` to manage its state, modifying existing sync services to update this state, and creating a new API endpoint (`GET /garmin/sync/status`) for users to monitor progress.
## Technical Context
**Language/Version**: Python 3.13
**Primary Dependencies**: FastAPI, Pydantic, `garth`, `garminconnect`, `httpx`
**Storage**: In-memory for `CurrentSyncJobManager`
**Testing**: Pytest
**Target Platform**: Linux server
**Project Type**: Web application (backend)
**Performance Goals**:
- Users can successfully initiate any sync operation and receive an initial confirmation within 2 seconds.
- The `GET /garmin/sync/status` API endpoint responds with the current sync status within 500ms, even under moderate load (e.g., 10 requests per second).
- Sync progress updates are reflected in the `GET /garmin/sync/status` API with a granularity that provides meaningful feedback to the user (e.g., progress updates at least every 10% completion or every 30 seconds for long syncs).
**Constraints**: Single-user system, only one sync job active at a time.
**Scale/Scope**: Single user, managing personal Garmin data synchronization.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
All gates pass. No violations.
## Project Structure
### Documentation (this feature)
```
specs/004-home-sstent-projects/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```
backend/
├── src/
│ ├── models/ # For SyncJob model
│ ├── services/ # For CurrentSyncJobManager and sync logic
│ └── api/ # For garmin_sync API endpoints
└── tests/
├── unit/
├── integration/
└── api/
```
**Structure Decision**: The existing "Option 2: Web application" structure is appropriate and will be used. New files will be added to `backend/src/models/` (for `SyncJob`), `backend/src/services/` (for `CurrentSyncJobManager`), and `backend/src/api/` (for the new status endpoint and modifications to existing sync endpoints).
## Complexity Tracking
N/A

View File

@@ -0,0 +1,109 @@
# Quickstart: Garmin Sync with Progress Tracking
This guide provides a quick overview of how to use the simplified Garmin sync functionality with progress tracking.
## 1. Initiate a Synchronization
To start a synchronization for activities, workouts, or health metrics, send a POST request to the respective endpoint. The system will only allow one sync to run at a time.
### Sync Activities
```bash
curl -X POST "http://localhost:8000/api/v1/garmin/activities" \
-H "accept: application/json"
```
### Sync Workouts
```bash
curl -X POST "http://localhost:8000/api/v1/garmin/workouts" \
-H "accept: application/json"
```
### Sync Health Metrics
```bash
curl -X POST "http://localhost:8000/api/v1/garmin/health" \
-H "accept: application/json"
```
**Expected Response (Success)**:
```json
{
"message": "Activity synchronization initiated successfully."
}
```
**Expected Response (Conflict - if another sync is in progress)**:
```json
{
"detail": "A synchronization is already in progress. Please wait or check status."
}
```
## 2. Poll for Sync Status
To check the current status and progress of the active synchronization job, send a GET request to the status endpoint. This endpoint will always return the status of the single active sync.
```bash
curl -X GET "http://localhost:8000/api/v1/garmin/sync/status" \
-H "accept: application/json"
```
**Expected Response (Sync in Progress)**:
```json
{
"status": "in_progress",
"progress": 0.5,
"start_time": "2025-10-11T10:00:00Z",
"end_time": null,
"error_message": null,
"job_type": "activities"
}
```
**Expected Response (Sync Completed)**:
```json
{
"status": "completed",
"progress": 1.0,
"start_time": "2025-10-11T10:00:00Z",
"end_time": "2025-10-11T10:15:00Z",
"error_message": null,
"job_type": "activities"
}
```
**Expected Response (Sync Failed)**:
```json
{
"status": "failed",
"progress": 0.75,
"start_time": "2025-10-11T10:00:00Z",
"end_time": "2025-10-11T10:10:00Z",
"error_message": "Failed to connect to Garmin API.",
"job_type": "activities"
}
```
**Expected Response (No Sync Active)**:
```json
{
"status": "idle",
"progress": 0.0,
"start_time": "1970-01-01T00:00:00Z",
"end_time": null,
"error_message": null,
"job_type": null
}
```
## 3. Handling Concurrent Sync Attempts
If you attempt to initiate a new sync while another is already in progress, the system will return a `409 Conflict` status code with an informative message. You must wait for the current sync to complete or fail before starting a new one.

View File

@@ -0,0 +1,66 @@
# Feature Specification: Simplify Sync Job Management with Progress Tracking
**Feature Branch**: `004-home-sstent-projects`
**Created**: Saturday, October 11, 2025
**Status**: Draft
**Input**: User description: "since this is a single user system can we simplify the sync jobs even further - i.e. just one sync job at a time, no queue, no job id? can we add progress tracking for the sync - with an API I can poll for updates"
## User Scenarios & Testing
### User Story 1 - Initiate a Sync and Monitor Progress (Priority: P1)
As a user, I want to initiate a data synchronization (activities, health, or workouts) and be able to monitor its progress in real-time, so I know the system is working and when it's complete or if it has failed.
**Why this priority**: This is core functionality for providing user feedback and ensuring a transparent experience during data synchronization, which is a primary function of the system.
**Independent Test**: This can be fully tested by initiating any type of sync (e.g., activity sync) and repeatedly querying the status API until the sync completes or fails. It delivers immediate value by informing the user about the state of their data synchronization.
**Acceptance Scenarios**:
1. **Given** no sync is currently in progress, **When** the user initiates an activity sync via the API, **Then** the system returns an immediate success confirmation, and subsequent calls to the `/garmin/sync/status` API show the sync as "in_progress" with increasing `progress` and `job_type` set to "activities".
2. **Given** a sync is currently in progress, **When** the user attempts to initiate another sync (e.g., health sync), **Then** the system returns a "409 Conflict" error, indicating that another sync is already running.
3. **Given** a sync is in progress, **When** the user repeatedly polls the `/garmin/sync/status` API, **Then** the API consistently returns the current `status` as "in_progress", the `progress` value reflecting the ongoing work, and the correct `job_type`.
4. **Given** a sync completes successfully, **When** the user polls the `/garmin/sync/status` API, **Then** the API returns the `status` as "completed", `progress` as 1.0, and the `end_time` is set.
5. **Given** a sync encounters an error and fails, **When** the user polls the `/garmin/sync/status` API, **Then** the API returns the `status` as "failed", `progress` reflecting the point of failure, an `error_message`, and the `end_time` is set.
6. **Given** no sync is active, **When** the user polls the `/garmin/sync/status` API, **Then** the API returns the `status` as "idle" and `progress` as 0.0.
---
### Edge Cases
- **Server Restart During Sync**: If the server restarts while a sync is in progress, the sync state will be lost, and the system will revert to an "idle" state upon restart. The user would need to re-initiate the sync.
- **Network Interruption During Sync**: If a network interruption occurs during a sync operation, the sync will likely fail and report an appropriate error message via the status API.
- **Rapid Polling**: The system should gracefully handle frequent polling requests to the status API without performance degradation.
## Requirements
### Functional Requirements
- **FR-001**: System MUST allow initiation of a single sync operation (activities, health, or workouts) at a time.
- **FR-002**: System MUST prevent initiation of a new sync if one is already in progress, returning a "409 Conflict" error.
- **FR-003**: System MUST provide a `GET /garmin/sync/status` API endpoint to retrieve the current status of the active sync job.
- **FR-004**: The `GET /garmin/sync/status` API MUST return a `SyncJob` object containing `status` (idle, in_progress, completed, failed), `progress` (0.0-1.0), `job_type` (activities, health, workouts), `start_time`, `end_time` (optional), and `error_message` (optional).
- **FR-005**: System MUST update the `progress` of the active sync job as it proceeds through its various stages.
- **FR-006**: System MUST mark the active sync job as "completed" and set its `end_time` upon successful completion.
- **FR-007**: System MUST mark the active sync job as "failed", record an `error_message`, and set its `end_time` upon failure.
- **FR-008**: System MUST initialize the `SyncJob` status to "idle" and `progress` to 0.0 when no sync is active.
### Key Entities
- **SyncJob**: Represents the state and progress of the single active synchronization process.
* `status`: String indicating the current state of the sync (e.g., "idle", "in_progress", "completed", "failed").
* `progress`: Float value from 0.0 to 1.0 representing the completion percentage of the sync.
* `start_time`: Datetime object indicating when the sync operation began.
* `end_time`: Optional Datetime object indicating when the sync operation concluded (either completed or failed).
* `error_message`: Optional string containing details if the sync operation failed.
* `job_type`: String indicating the type of data being synchronized (e.g., "activities", "health", "workouts").
## Success Criteria
### Measurable Outcomes
- **SC-001**: Users can successfully initiate any sync operation and receive an initial confirmation within 2 seconds.
- **SC-002**: The `GET /garmin/sync/status` API endpoint responds with the current sync status within 500ms, even under moderate load (e.g., 10 requests per second).
- **SC-003**: Sync progress updates are reflected in the `GET /garmin/sync/status` API with a granularity that provides meaningful feedback to the user (e.g., progress updates at least every 10% completion or every 30 seconds for long-running syncs).
- **SC-004**: The system accurately reports sync completion or failure, including relevant error messages, for 100% of sync attempts.
- **SC-005**: The system successfully prevents concurrent sync initiations, returning a 409 Conflict error in 100% of such attempts.

View File

@@ -0,0 +1,104 @@
# Tasks: Simplify Sync Job Management with Progress Tracking
**Feature Branch**: `004-home-sstent-projects` | **Date**: Saturday, October 11, 2025 | **Spec**: /home/sstent/Projects/FitTrack_GarminSync/specs/004-home-sstent-projects/spec.md
## Phase 1: Setup Tasks
*(No specific setup tasks identified beyond the existing project structure. Foundational components will be created in Phase 2.)*
## Phase 2: Foundational Tasks
These tasks establish the core components required for the single sync job management and progress tracking.
- [X] **T001**: Create `backend/src/models/sync_job.py` to define the `SyncJob` Pydantic model with `status`, `progress`, `start_time`, `end_time`, `error_message`, and `job_type` attributes. [P]
- [X] **T002**: Create `backend/src/services/sync_manager.py` to implement the `CurrentSyncJobManager` (singleton) with methods `start_sync`, `update_progress`, `complete_sync`, `fail_sync`, `get_current_sync_status`, and `is_sync_active`. [P]
- [X] **T003**: Update `backend/src/dependencies.py` to remove references to the old `job_store` and `SyncStatusService`. [P]
- [X] **T004**: Delete `backend/src/jobs.py`. [P]
- [X] **T005**: Delete `backend/src/services/sync_status_service.py`. [P]
## Phase 3: User Story 1 - Initiate a Sync and Monitor Progress (P1)
**Story Goal**: As a user, I want to initiate a data synchronization (activities, health, or workouts) and be able to monitor its progress in real-time, so I know the system is working and when it's complete or if it has failed.
**Independent Test Criteria**: This can be fully tested by initiating any type of sync (e.g., activity sync) and repeatedly querying the status API until the sync completes or fails. It delivers immediate value by informing the user about the state of their data synchronization.
- [X] **T006** [US1]: Modify `backend/src/api/garmin_sync.py` to import `CurrentSyncJobManager` and `SyncJob` from the new modules. [P]
- [X] **T007** [US1]: Modify `backend/src/api/garmin_sync.py` to remove the old `SyncJob` import and the `/status/{job_id}` endpoint. [P]
- [X] **T008** [US1]: Modify `backend/src/api/garmin_sync.py` to update the `POST /garmin/activities` endpoint:
* Implement the single-sync enforcement using `CurrentSyncJobManager.is_sync_active()`.
* Call `CurrentSyncJobManager.start_sync(job_type="activities")`.
* Pass the `SyncJob` instance (or a reference to the `CurrentSyncJobManager`) to `garmin_activity_service.sync_activities_in_background`.
* Change the `response_model` to a simple success message. [P]
- [X] **T009** [US1]: Modify `backend/src/api/garmin_sync.py` to update the `POST /garmin/workouts` endpoint:
* Implement the single-sync enforcement using `CurrentSyncJobManager.is_sync_active()`.
* Call `CurrentSyncJobManager.start_sync(job_type="workouts")`.
* Pass the `SyncJob` instance (or a reference to the `CurrentSyncJobManager`) to `garmin_workout_service.upload_workout_in_background`.
* Change the `response_model` to a simple success message. [P]
- [X] **T010** [US1]: Modify `backend/src/api/garmin_sync.py` to update the `POST /garmin/health` endpoint:
* Implement the single-sync enforcement using `CurrentSyncJobManager.is_sync_active()`.
* Call `CurrentSyncJobManager.start_sync(job_type="health")`.
* Pass the `SyncJob` instance (or a reference to the `CurrentSyncJobManager`) to `garmin_health_service.sync_health_metrics_in_background`.
* Change the `response_model` to a simple success message. [P]
- [X] **T011** [US1]: Add `GET /garmin/sync/status` API endpoint to `backend/src/api/garmin_sync.py` that returns the current `SyncJob` status from `CurrentSyncJobManager.get_current_sync_status()`. [P]
- [X] **T012** [US1]: Modify `backend/src/services/garmin_activity_service.py`:
* Remove `job_id` parameter from `sync_activities_in_background`.
* Accept `SyncJob` instance (or `CurrentSyncJobManager`) as a parameter.
* Replace `job_store.update_job` calls with `CurrentSyncJobManager.update_progress`, `complete_sync`, and `fail_sync`. [P]
- [X] **T013** [US1]: Modify `backend/src/services/garmin_workout_service.py`:
* Remove `job_id` parameter from `upload_workout_in_background`.
* Accept `SyncJob` instance (or `CurrentSyncJobManager`) as a parameter.
* Replace `job_store.update_job` calls with `CurrentSyncJobManager.update_progress`, `complete_sync`, and `fail_sync`. [P]
- [X] **T014** [US1]: Modify `backend/src/services/garmin_health_service.py`:
* Remove `job_id` parameter from `sync_health_metrics_in_background`.
* Accept `SyncJob` instance (or `CurrentSyncJobManager`) as a parameter.
* Replace `job_store.update_job` calls with `CurrentSyncJobManager.update_progress`, `complete_sync`, and `fail_sync`. [P]
- [X] **T015** [US1]: Update `backend/src/main.py` to ensure `CurrentSyncJobManager` is properly initialized and accessible (e.g., as a global or via dependency injection if preferred). [P]
## Phase 4: Polish & Cross-Cutting Concerns
- [X] **T016**: Add unit tests for `backend/src/models/sync_job.py`. [P]
- [X] **T017**: Add unit tests for `backend/src/services/sync_manager.py`. [P]
- [X] **T018**: Add API integration tests for `GET /garmin/sync/status` endpoint. [P]
- [X] **T019**: Add API integration tests for `POST /garmin/activities` (success and conflict scenarios). [P]
- [X] **T020**: Add API integration tests for `POST /garmin/workouts` (success and conflict scenarios). [P]
- [X] **T021**: Add API integration tests for `POST /garmin/health` (success and conflict scenarios). [P]
- [X] **T022**: Ensure all new and modified code adheres to Python 3.13 style guidelines (type hints, Black formatting, Flake8 linting). [P]
## Dependencies
```mermaid
graph TD
A[T001: Create SyncJob Model] --> B(T002: Create SyncManager Service)
A --> C(T003: Update dependencies.py)
A --> D(T004: Delete jobs.py)
A --> E(T005: Delete sync_status_service.py)
B --> F(T006: Modify garmin_sync.py imports)
B --> G(T012: Modify garmin_activity_service.py)
B --> H(T013: Modify garmin_workout_service.py)
B --> I(T014: Modify garmin_health_service.py)
F --> J(T007: Modify garmin_sync.py remove old endpoint)
J --> K(T008: Modify POST /garmin/activities)
J --> L(T009: Modify POST /garmin/workouts)
J --> M(T010: Modify POST /garmin/health)
J --> N(T011: Add GET /garmin/sync/status)
K --> O(T015: Update main.py)
L --> O
M --> O
N --> O
O --> P(T016: Unit tests for SyncJob)
O --> Q(T017: Unit tests for SyncManager)
O --> R(T018: API tests for GET /garmin/sync/status)
O --> S(T019: API tests for POST /garmin/activities)
O --> T(T020: API tests for POST /garmin/workouts)
O --> U(T021: API tests for POST /garmin/health)
O --> V(T022: Code style adherence)
```
## Parallel Execution Examples
* **After T005 (Foundational Tasks)**: T006, T007, T012, T013, T014 can be worked on in parallel.
* **After T015 (User Story 1 Implementation)**: T016, T017, T018, T019, T020, T021, T022 (all testing and polish tasks) can be worked on in parallel.
## Implementation Strategy
This feature will be implemented using an MVP-first approach, focusing on delivering the core functionality of User Story 1. The tasks are ordered to build foundational components first, then implement the core user story functionality, and finally add comprehensive testing and polish. Each user story phase is designed to be an independently testable increment.