diff --git a/GEMINI.md b/GEMINI.md index 3d8b742..e8e15ee 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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) - 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 ``` @@ -20,6 +22,7 @@ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLO Python 3.13: Follow standard conventions ## 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` diff --git a/backend/src/api/garmin_auth.py b/backend/src/api/garmin_auth.py index 0ff1ff9..6f519f7 100644 --- a/backend/src/api/garmin_auth.py +++ b/backend/src/api/garmin_auth.py @@ -1,8 +1,12 @@ - -from fastapi import APIRouter, Depends, HTTPException, status import logging -from ..dependencies import get_central_db_service, get_garmin_auth_service, get_garmin_client_service +from 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 ..services.central_db_service import CentralDBService from ..services.garmin_auth_service import GarminAuthService @@ -12,7 +16,10 @@ logger = logging.getLogger(__name__) 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( request: GarminLoginRequest, garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service), @@ -32,55 +39,74 @@ async def garmin_login( # Update GarminClientService with existing credentials garmin_client_service.update_credentials( existing_credentials.garmin_username, - existing_credentials.garmin_password_plaintext + existing_credentials.garmin_password_plaintext, ) # Check if already authenticated or if session is still valid - if garmin_client_service.is_authenticated() and garmin_client_service.check_session_validity(): - logger.info(f"Garmin client already authenticated and session valid for {existing_credentials.garmin_username}. Reusing session.") + if ( + 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.") else: - logger.info(f"Garmin client not authenticated or session invalid for {existing_credentials.garmin_username}. Attempting to re-authenticate with existing credentials.") - if garmin_client_service.authenticate(): # Only authenticate if not already valid - logger.info(f"Successfully re-authenticated Garmin client with existing credentials for {existing_credentials.garmin_username}.") - return GarminLoginResponse(message="Garmin account linked successfully.") + logger.info( + f"Garmin client not authenticated or session invalid for " + f"{existing_credentials.garmin_username}. Attempting to re-authenticate " + "with existing credentials." + ) + if ( + garmin_client_service.authenticate() + ): # Only authenticate if not already valid + logger.info( + f"Successfully re-authenticated Garmin client with existing " + f"credentials for {existing_credentials.garmin_username}." + ) + return GarminLoginResponse( + message="Garmin account linked successfully." + ) else: - logger.warning(f"Failed to re-authenticate with existing Garmin credentials for {existing_credentials.garmin_username}. Proceeding with fresh login attempt.") + logger.warning( + f"Failed to re-authenticate with existing Garmin credentials for " + f"{existing_credentials.garmin_username}. Proceeding with fresh login attempt." + ) else: - logger.info(f"No existing Garmin credentials found for user {user_id}. Proceeding with fresh login.") + logger.info( + f"No existing Garmin credentials found for user {user_id}. Proceeding with fresh login." + ) # If no existing credentials, or existing credentials failed, perform a fresh login garmin_credentials = await garmin_auth_service.initial_login( - request.username, - request.password + request.username, request.password ) if not garmin_credentials: raise HTTPException( 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 if existing_credentials: updated_credentials = await central_db_service.update_garmin_credentials( - user_id, - garmin_credentials.model_dump() + user_id, garmin_credentials.model_dump() ) if not updated_credentials: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update Garmin credentials in CentralDB." + detail="Failed to update Garmin credentials in CentralDB.", ) else: created_credentials = await central_db_service.create_garmin_credentials( - user_id, - garmin_credentials.model_dump() + user_id, garmin_credentials.model_dump() ) if not created_credentials: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to store Garmin credentials in CentralDB." + detail="Failed to store Garmin credentials in CentralDB.", ) return GarminLoginResponse(message="Garmin account linked successfully.") diff --git a/backend/src/api/garmin_sync.py b/backend/src/api/garmin_sync.py index f876087..dbf8fa3 100644 --- a/backend/src/api/garmin_sync.py +++ b/backend/src/api/garmin_sync.py @@ -1,47 +1,57 @@ -from typing import List, Optional -from uuid import UUID +from typing import Optional -from fastapi import APIRouter, BackgroundTasks, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from ..dependencies import ( get_current_user, get_garmin_activity_service, + get_garmin_health_service, # Added this line 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 ..services.garmin_activity_service import GarminActivityService +from ..services.garmin_health_service import GarminHealthService from ..services.garmin_workout_service import GarminWorkoutService -from ..services.sync_status_service import SyncStatusService +from ..services.sync_manager import current_sync_job_manager 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( request: ActivitySyncRequest, 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), - 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 """ - 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( garmin_activity_service.sync_activities_in_background, - job.id, + current_user.user_id, + current_sync_job_manager, request.force_resync, request.start_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( request: WorkoutUploadRequest, background_tasks: BackgroundTasks, @@ -51,31 +61,51 @@ async def upload_garmin_workout( """ 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( garmin_workout_service.upload_workout_in_background, - job.id, + current_user.user_id, + current_sync_job_manager, 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( - job_id: UUID, - limit: int = 10, - offset: int = 0, - sync_status_service: SyncStatusService = Depends(get_sync_status_service), + +@router.post("/garmin/health", status_code=202) +async def trigger_garmin_health_sync( + background_tasks: BackgroundTasks, + garmin_health_service: GarminHealthService = Depends(get_garmin_health_service), 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( - job_id=job_id, - limit=limit, - offset=offset + 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="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() diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/api/default/upload_activity_activities_post.py b/backend/src/central_db_client/fit_track_central_db_api_client/api/default/upload_activity_activities_post.py index 54061ba..6a2024c 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/api/default/upload_activity_activities_post.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/api/default/upload_activity_activities_post.py @@ -6,7 +6,9 @@ import httpx from ... import errors from ...client import AuthenticatedClient, Client 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 ...types import Response diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/api/root/read_root_get.py b/backend/src/central_db_client/fit_track_central_db_api_client/api/root/read_root_get.py index 6b0a126..1967c28 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/api/root/read_root_get.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/api/root/read_root_get.py @@ -17,7 +17,9 @@ def _get_kwargs() -> dict[str, Any]: 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: return None @@ -27,7 +29,9 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt 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( status_code=HTTPStatus(response.status_code), content=response.content, diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/client.py b/backend/src/central_db_client/fit_track_central_db_api_client/client.py index e80446f..eeffd00 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/client.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/client.py @@ -38,9 +38,15 @@ class Client: _base_url: str = field(alias="base_url") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") - _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") - _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") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _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") _client: Optional[httpx.Client] = 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") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") - _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") - _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") + _timeout: Optional[httpx.Timeout] = field( + default=None, kw_only=True, alias="timeout" + ) + _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") _client: Optional[httpx.Client] = 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: """Get the underlying httpx.Client, constructing a new one if not previously set""" 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( base_url=self._base_url, cookies=self._cookies, @@ -235,7 +249,9 @@ class AuthenticatedClient: """Exit a context manager for internal httpx.Client (see httpx docs)""" 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 **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: """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" 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( base_url=self._base_url, cookies=self._cookies, diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/activity.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/activity.py index 1b0cbf0..4cc6775 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/activity.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/activity.py @@ -34,7 +34,9 @@ class Activity: additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) 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 @@ -69,7 +71,9 @@ class Activity: @classmethod 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) user_id = d.pop("user_id") @@ -80,7 +84,9 @@ class Activity: 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: return data if isinstance(data, Unset): diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/coaching_session_create.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/coaching_session_create.py index 21fd3d7..aaf975b 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/coaching_session_create.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/coaching_session_create.py @@ -5,7 +5,9 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field if TYPE_CHECKING: - from ..models.coaching_session_create_conversation import CoachingSessionCreateConversation + from ..models.coaching_session_create_conversation import ( + CoachingSessionCreateConversation, + ) T = TypeVar("T", bound="CoachingSessionCreate") @@ -36,10 +38,14 @@ class CoachingSessionCreate: @classmethod 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) - conversation = CoachingSessionCreateConversation.from_dict(d.pop("conversation")) + conversation = CoachingSessionCreateConversation.from_dict( + d.pop("conversation") + ) coaching_session_create = cls( conversation=conversation, diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/user.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/user.py index 640dbc2..ddef3d0 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/user.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/user.py @@ -71,7 +71,9 @@ class User: 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: return data if isinstance(data, Unset): diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/user_create.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/user_create.py index ff27f18..9e4dbf6 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/user_create.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/user_create.py @@ -64,7 +64,9 @@ class UserCreate: 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: return data if isinstance(data, Unset): diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/user_update.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/user_update.py index 1b5b7b0..dc283e6 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/user_update.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/user_update.py @@ -86,7 +86,9 @@ class UserUpdate: 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: return data if isinstance(data, Unset): diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_create.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_create.py index 168beed..b9e17f6 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_create.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_create.py @@ -41,7 +41,9 @@ class WorkoutPlanCreate: @classmethod 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) user_id = d.pop("user_id") diff --git a/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_update.py b/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_update.py index 5cca1c9..97092ca 100644 --- a/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_update.py +++ b/backend/src/central_db_client/fit_track_central_db_api_client/models/workout_plan_update.py @@ -7,7 +7,9 @@ from attrs import field as _attrs_field from ..types import UNSET, Unset 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") @@ -24,7 +26,9 @@ class WorkoutPlanUpdate: additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) 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]] if isinstance(self.plan_details, Unset): @@ -44,11 +48,15 @@ class WorkoutPlanUpdate: @classmethod 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) - def _parse_plan_details(data: object) -> Union["WorkoutPlanUpdatePlanDetailsType0", None, Unset]: + def _parse_plan_details( + data: object, + ) -> Union["WorkoutPlanUpdatePlanDetailsType0", None, Unset]: if data is None: return data if isinstance(data, Unset): diff --git a/backend/src/config.py b/backend/src/config.py index bb32894..0a8f35e 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -1,11 +1,12 @@ import logging from pathlib import Path -from typing import Tuple +from typing import Optional, Tuple from pydantic_settings import BaseSettings logger = logging.getLogger(__name__) + class Settings(BaseSettings): APP_NAME: str = "GarminSync Backend" DEBUG: bool = False @@ -18,12 +19,14 @@ class Settings(BaseSettings): GARMIN_CONNECT_EMAIL: str = "" GARMIN_CONNECT_PASSWORD: str = "" CENTRAL_DB_URL: str + DATABASE_URL: Optional[str] = None # Added to handle potential old .env variable GARMINSYNC_DATA_DIR: Path = Path("data") class Config: env_file = ".env" env_file_encoding = "utf-8" + settings = Settings() # Create data directory if it doesn't exist @@ -31,6 +34,7 @@ settings = Settings() _deprecation_warned = False + def get_garmin_credentials() -> Tuple[str, str]: """Get Garmin Connect credentials from environment variables. diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index 9038fec..10f1c03 100644 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -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_health_service import GarminHealthService from .services.garmin_workout_service import GarminWorkoutService -from .services.sync_status_service import SyncStatusService def get_central_db_service() -> CentralDBService: return CentralDBService(base_url=settings.CENTRAL_DB_URL) + def get_auth_service() -> AuthService: return AuthService() -def get_garmin_auth_service() -> GarminAuthService: # New dependency function + +def get_garmin_auth_service() -> GarminAuthService: # New dependency function return GarminAuthService() + def get_garmin_client_service() -> GarminClientService: return garmin_client_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: return ActivityDownloadService(garmin_client_instance=garmin_client_service) + def get_garmin_activity_service( garmin_client_service: GarminClientService = Depends(get_garmin_client_service), - activity_download_service: ActivityDownloadService = Depends(get_activity_download_service), + activity_download_service: ActivityDownloadService = Depends( + get_activity_download_service + ), garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service), - central_db_service: CentralDBService = Depends(get_central_db_service) + central_db_service: CentralDBService = Depends(get_central_db_service), ) -> GarminActivityService: return GarminActivityService( garmin_client_service=garmin_client_service, activity_download_service=activity_download_service, garmin_auth_service=garmin_auth_service, - central_db_service=central_db_service + central_db_service=central_db_service, ) + def get_garmin_health_service( garmin_client_service: GarminClientService = Depends(get_garmin_client_service), central_db_service: CentralDBService = Depends(get_central_db_service), - garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service) + garmin_auth_service: GarminAuthService = Depends(get_garmin_auth_service), ) -> GarminHealthService: return GarminHealthService( garmin_client_service=garmin_client_service, central_db_service=central_db_service, - garmin_auth_service=garmin_auth_service + garmin_auth_service=garmin_auth_service, ) + 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( - central_db_service: CentralDBService = Depends(get_central_db_service) + central_db_service: CentralDBService = Depends(get_central_db_service), ) -> User: # As per spec, this is a single-user system, so we can assume user_id = 1 user_id = 1 diff --git a/backend/src/jobs.py b/backend/src/jobs.py deleted file mode 100644 index 1b6b00e..0000000 --- a/backend/src/jobs.py +++ /dev/null @@ -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() diff --git a/backend/src/logging_config.py b/backend/src/logging_config.py index 6fbc638..230450a 100644 --- a/backend/src/logging_config.py +++ b/backend/src/logging_config.py @@ -2,7 +2,7 @@ import logging LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + def setup_logging(): logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) # You can add more sophisticated logging handlers here, e.g., file handlers, Sentry, etc. - diff --git a/backend/src/main.py b/backend/src/main.py index 688b1d1..836a746 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -20,7 +20,9 @@ app = FastAPI(title=settings.APP_NAME) rate_limiter = RateLimiter(rate_limit="100/minute") # Initialize CentralDBService -central_db_service = CentralDBService(base_url=settings.CENTRAL_DB_URL) # Assuming CENTRAL_DB_URL in settings +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_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"} + @app.post("/logout") async def logout(response: Response): 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, content={ "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("/") async def root(): return {"message": "Welcome to GarminSync Backend!"} + @app.post("/background-test", dependencies=[Depends(rate_limiter)]) async def run_background_test(background_tasks: BackgroundTasks): message = "This is a test background task." diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/models/sync_job.py b/backend/src/models/sync_job.py new file mode 100644 index 0000000..a71952f --- /dev/null +++ b/backend/src/models/sync_job.py @@ -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 diff --git a/backend/src/schemas.py b/backend/src/schemas.py index 108dbf6..0a48ccc 100644 --- a/backend/src/schemas.py +++ b/backend/src/schemas.py @@ -8,9 +8,11 @@ class UserBase(BaseModel): name: str email: str + class UserCreate(UserBase): pass + class User(UserBase): id: int preferences: Optional[Dict[str, Any]] = None @@ -18,17 +20,21 @@ class User(UserBase): class Config: from_attributes = True + class TokenBase(BaseModel): access_token: str refresh_token: str - expires_at: int # Unix timestamp + expires_at: int # Unix timestamp + class TokenCreate(TokenBase): user_id: int + class TokenUpdate(TokenBase): user_id: int + class Token(TokenBase): id: int user_id: int @@ -38,15 +44,18 @@ class Token(TokenBase): class Config: from_attributes = True + class WorkoutPlan(BaseModel): id: int user_id: int plan_details: Dict[str, Any] created_at: datetime + class ActivitySyncRequest(BaseModel): 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( None, @@ -59,16 +68,22 @@ class ActivitySyncRequest(BaseModel): None, description="Optional end date (YYYY-MM-DD) to sync activities up to." ) + class WorkoutUploadRequest(BaseModel): - workout_id: int = Field(..., description="The ID of the workout to upload from CentralDB.") + workout_id: int = Field( + ..., description="The ID of the workout to upload from CentralDB." + ) + class GarminCredentials(BaseModel): garmin_username: str - garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk. + garmin_password_plaintext: str # NOTE: Storing in plaintext as per user requirement. This is a security risk. + class GarminLoginRequest(BaseModel): username: str password: str + class GarminLoginResponse(BaseModel): message: str diff --git a/backend/src/services/activity_download_service.py b/backend/src/services/activity_download_service.py index 0cc7c53..665bd24 100644 --- a/backend/src/services/activity_download_service.py +++ b/backend/src/services/activity_download_service.py @@ -8,11 +8,14 @@ from ..config import settings logger = logging.getLogger(__name__) + class ActivityDownloadService: def __init__(self, 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). Args: @@ -36,7 +39,7 @@ class ActivityDownloadService: attempts: List[str] = [] # 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: attempts.append( "self.garmin_client.client.download_activity_original(activity_id)" @@ -44,7 +47,9 @@ class ActivityDownloadService: logger.debug( 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: logger.debug( f"Native download_activity_original failed: {e} (type={type(e).__name__})" @@ -52,7 +57,9 @@ class ActivityDownloadService: file_data = None # 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: attempts.append( "self.garmin_client.client.download_activity(activity_id, " @@ -64,14 +71,19 @@ class ActivityDownloadService: f"for activity {activity_id}" ) 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( f"download_activity(dl_fmt='original') succeeded, got data type: " f"{type(file_data).__name__}, length: " f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}" ) - if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0: + if ( + file_data is not None + and hasattr(file_data, "__len__") + and len(file_data) > 0 + ): logger.debug(f"First 100 bytes: {file_data[:100]}") except Exception as e: logger.debug( @@ -80,8 +92,10 @@ class ActivityDownloadService: file_data = None # 3) Try download_activity with positional token (older signatures) - if file_data is None and hasattr(self.garmin_client.client, 'download_activity'): - tokens_to_try_pos = ['ORIGINAL', 'original', 'FIT', 'fit'] + if file_data is None and hasattr( + self.garmin_client.client, "download_activity" + ): + tokens_to_try_pos = ["ORIGINAL", "original", "FIT", "fit"] for token in tokens_to_try_pos: try: attempts.append( @@ -91,13 +105,19 @@ class ActivityDownloadService: "Attempting original download via download_activity(" f"activity_id, '{token}') for activity {activity_id}" ) - file_data = self.garmin_client.client.download_activity(activity_id, token) + file_data = self.garmin_client.client.download_activity( + activity_id, token + ) logger.debug( f"download_activity(activity_id, '{token}') succeeded, got data type: " f"{type(file_data).__name__}, length: " f"{len(file_data) if hasattr(file_data, '__len__') else 'N/A'}" ) - if file_data is not None and hasattr(file_data, '__len__') and len(file_data) > 0: + if ( + file_data is not None + and hasattr(file_data, "__len__") + and len(file_data) > 0 + ): logger.debug(f"First 100 bytes: {file_data[:100]}") break except Exception as e: @@ -113,12 +133,12 @@ class ActivityDownloadService: ) return None - if hasattr(file_data, 'content'): + if hasattr(file_data, "content"): try: file_data = file_data.content except Exception: pass - elif hasattr(file_data, 'read'): + elif hasattr(file_data, "read"): try: file_data = file_data.read() except Exception: @@ -136,21 +156,29 @@ class ActivityDownloadService: tmp_file.write(file_data) 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): - with zipfile.ZipFile(tmp_path, 'r') as zip_ref: - fit_files = [f for f in zip_ref.namelist() if f.lower().endswith('.fit')] + with zipfile.ZipFile(tmp_path, "r") as zip_ref: + fit_files = [ + f for f in zip_ref.namelist() if f.lower().endswith(".fit") + ] if fit_files: fit_filename = fit_files[0] - with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target: + with zip_ref.open(fit_filename) as source, open( + extracted_path, "wb" + ) as target: target.write(source.read()) tmp_path.unlink() - logger.info(f"Downloaded original activity file: {extracted_path}") + logger.info( + f"Downloaded original activity file: {extracted_path}" + ) downloaded_path = extracted_path else: logger.warning("No FIT file found in downloaded archive") @@ -164,7 +192,9 @@ class ActivityDownloadService: f"Rename temp FIT to destination failed ({move_err}); " "falling back to copy" ) - with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source: + with open(extracted_path, "wb") as target, open( + tmp_path, "rb" + ) as source: target.write(source.read()) tmp_path.unlink() downloaded_path = extracted_path diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py index c012b28..0caaab3 100644 --- a/backend/src/services/auth_service.py +++ b/backend/src/services/auth_service.py @@ -11,6 +11,7 @@ from .central_db_service import CentralDBService logger = logging.getLogger(__name__) + class AuthService: def __init__(self): self.central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL) @@ -24,13 +25,17 @@ class AuthService: if not session_cookie: return None 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) return user except Exception: 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. """ diff --git a/backend/src/services/background_tasks.py b/backend/src/services/background_tasks.py index aa313fe..556f527 100644 --- a/backend/src/services/background_tasks.py +++ b/backend/src/services/background_tasks.py @@ -3,6 +3,7 @@ import time logger = logging.getLogger(__name__) + def example_background_task(message: str): logger.info(f"Starting background task with message: {message}") time.sleep(5) # Simulate a long-running task diff --git a/backend/src/services/central_db_service.py b/backend/src/services/central_db_service.py index f660029..7817d01 100644 --- a/backend/src/services/central_db_service.py +++ b/backend/src/services/central_db_service.py @@ -1,11 +1,16 @@ import logging from pathlib import Path -from typing import Optional, List +from typing import Optional + 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 ..config import settings logger = logging.getLogger(__name__) @@ -13,10 +18,11 @@ logger = logging.getLogger(__name__) CENTRAL_DB_RETRY_STRATEGY = retry( stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=10), - retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors - reraise=True + retry=retry_if_exception_type(httpx.RequestError), # Retry on network errors + reraise=True, ) + class CentralDBService: def __init__(self, base_url: str): self.base_url = base_url @@ -73,7 +79,9 @@ class CentralDBService: async def create_token(self, token_create: dict) -> Optional[Token]: try: 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() return Token(**response.json()) except Exception as e: @@ -84,7 +92,9 @@ class CentralDBService: async def update_token(self, user_id: int, token_update: dict) -> Optional[Token]: try: 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() return Token(**response.json()) except Exception as e: @@ -95,7 +105,9 @@ class CentralDBService: async def get_workout_by_id(self, workout_id: int) -> Optional[WorkoutPlan]: try: 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() return WorkoutPlan(**response.json()) except Exception as e: @@ -108,24 +120,33 @@ class CentralDBService: try: async with httpx.AsyncClient() as client: with open(file_path, "rb") as f: - files = {"file": (file_path.name, f, "application/fit")} # Changed content type - user_id = 1 # Assuming single user for now + files = { + "file": (file_path.name, f, "application/fit") + } # Changed content type + user_id = 1 # Assuming single user for now response = await client.post( - f"{self.base_url}/activities/{user_id}", # user_id as path parameter + f"{self.base_url}/activities/{user_id}", # user_id as path parameter files=files, ) response.raise_for_status() - logger.info(f"Successfully uploaded activity {activity_id} to CentralDB.") + logger.info( + f"Successfully uploaded activity {activity_id} to CentralDB." + ) return True except Exception as e: - logger.error(f"Error uploading activity {activity_id} to CentralDB: {e}", exc_info=True) + logger.error( + f"Error uploading activity {activity_id} to CentralDB: {e}", + exc_info=True, + ) return False @CENTRAL_DB_RETRY_STRATEGY async def save_health_metric(self, health_metric_data: dict) -> Optional[dict]: try: async with httpx.AsyncClient() as client: - 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() return response.json() except Exception as e: @@ -136,7 +157,9 @@ class CentralDBService: async def get_garmin_credentials(self, user_id: int) -> Optional[GarminCredentials]: try: async with httpx.AsyncClient() as client: - response = await client.get(f"{self.base_url}/garmin_credentials/{user_id}") + response = await client.get( + f"{self.base_url}/garmin_credentials/{user_id}" + ) response.raise_for_status() return GarminCredentials(**response.json()) except Exception as e: @@ -144,10 +167,15 @@ class CentralDBService: return None @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: 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() return GarminCredentials(**response.json()) except Exception as e: @@ -155,10 +183,15 @@ class CentralDBService: return None @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: 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() return GarminCredentials(**response.json()) except Exception as e: diff --git a/backend/src/services/garmin_activity_service.py b/backend/src/services/garmin_activity_service.py index d22923f..a88439c 100644 --- a/backend/src/services/garmin_activity_service.py +++ b/backend/src/services/garmin_activity_service.py @@ -3,9 +3,13 @@ import logging from datetime import date, datetime 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.central_db_service import CentralDBService from ..services.garmin_auth_service import GarminAuthService @@ -17,15 +21,17 @@ logger = logging.getLogger(__name__) GARMIN_RETRY_STRATEGY = retry( stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(Exception), # Broad exception for now, refine later - reraise=True + retry=retry_if_exception_type(Exception), # Broad exception for now, refine later + reraise=True, ) + # Placeholder for SHA256 calculation - to be implemented in a utility module def calculate_sha256(file_path) -> str: # This is a placeholder. Actual implementation would read the file and compute SHA256. return "mock_sha256_checksum" + class GarminActivityService: def __init__( @@ -33,14 +39,16 @@ class GarminActivityService: garmin_client_service: GarminClientService, activity_download_service: ActivityDownloadService, garmin_auth_service: GarminAuthService, - central_db_service: CentralDBService + central_db_service: CentralDBService, ): self.garmin_client_service = garmin_client_service self.activity_download_service = activity_download_service self.garmin_auth_service = garmin_auth_service self.central_db_service = central_db_service - async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]: + async def _get_authenticated_garmin_client( + self, user_id: int + ) -> Optional[GarminClientService]: credentials = await self.central_db_service.get_garmin_credentials(user_id) if not credentials: logger.error(f"No Garmin credentials found for user {user_id}.") @@ -55,17 +63,24 @@ class GarminActivityService: # Check if the client is authenticated after updating credentials if not self.garmin_client_service.is_authenticated(): if not self.garmin_client_service.authenticate(): - logger.error(f"Failed to authenticate Garmin client for user {user_id}.") + logger.error( + f"Failed to authenticate Garmin client for user {user_id}." + ) return None return self.garmin_client_service @GARMIN_RETRY_STRATEGY async def download_and_save_activity( - self, user_id: int, activity_id: str, force_download: bool = False, - garmin_client: Optional[GarminClientService] = None # New argument + self, + user_id: int, + activity_id: str, + force_download: bool = False, + garmin_client: Optional[GarminClientService] = None, # New argument ) -> Optional[dict]: - _garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id) + _garmin_client = garmin_client or await self._get_authenticated_garmin_client( + user_id + ) if not _garmin_client: return None @@ -75,37 +90,49 @@ class GarminActivityService: # CentralDB will be responsible for handling duplicates. # Download the original activity file (FIT) - downloaded_file_path = self.activity_download_service.download_activity_original( - activity_id=activity_id, - force_download=force_download + downloaded_file_path = ( + self.activity_download_service.download_activity_original( + activity_id=activity_id, force_download=force_download + ) ) if not downloaded_file_path: - logger.error(f"Failed to download activity file for activity ID: {activity_id}") + logger.error( + f"Failed to download activity file for activity ID: {activity_id}" + ) return None # Upload the file to CentralDB # from .central_db_service import CentralDBService # No longer needed, injected # central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL) - success = await self.central_db_service.upload_activity_file(activity_id, downloaded_file_path) + success = await self.central_db_service.upload_activity_file( + activity_id, downloaded_file_path + ) if not success: - logger.error(f"Failed to upload activity file to CentralDB for activity ID: {activity_id}") + logger.error( + f"Failed to upload activity file to CentralDB for activity ID: {activity_id}" + ) return None # Get activity details from Garmin Connect (to extract metadata) - garmin_activity_details = _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 except Exception as e: 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 @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.""" garmin_client = await self._get_authenticated_garmin_client(user_id) if not garmin_client: @@ -122,20 +149,22 @@ class GarminActivityService: async def sync_activities_in_background( self, - job_id: str, + user_id: int, + current_sync_job_manager, force_resync: bool = False, start_date: Optional[date] = None, end_date: Optional[date] = None, - max_activities_to_sync: Optional[int] = 10, # Default to 10 activities + max_activities_to_sync: Optional[int] = 10, # Default to 10 activities ): - user_id = 1 # Assuming single user for now try: - job_store.update_job(job_id, status="in_progress", progress=0.0) + # user_id = 1 # Assuming single user for now - now passed as argument # Authenticate Garmin client once at the beginning garmin_client = await self._get_authenticated_garmin_client(user_id) if not garmin_client: - raise Exception("Garmin client not authenticated or failed to get valid credentials.") + raise Exception( + "Garmin client not authenticated or failed to get valid credentials." + ) all_garmin_activities = [] start = 0 @@ -148,7 +177,7 @@ class GarminActivityService: except Exception as e: logger.error( f"Failed to fetch activities from Garmin Connect after retries: {e}", - exc_info=True + exc_info=True, ) raise @@ -158,21 +187,37 @@ class GarminActivityService: start += limit # Break if we have collected enough activities - if max_activities_to_sync is not None and len(all_garmin_activities) >= max_activities_to_sync: - all_garmin_activities = all_garmin_activities[:max_activities_to_sync] # Truncate to the limit + if ( + max_activities_to_sync is not None + and len(all_garmin_activities) >= max_activities_to_sync + ): + all_garmin_activities = all_garmin_activities[ + :max_activities_to_sync + ] # Truncate to the limit break activities_to_process = [] for activity_data in all_garmin_activities: activity_start_time_str = activity_data.get("startTimeGMT") activity_start_time = ( - datetime.fromisoformat(activity_start_time_str.replace("Z", "+00:00")) - if activity_start_time_str else None + datetime.fromisoformat( + activity_start_time_str.replace("Z", "+00:00") + ) + if activity_start_time_str + else None ) - if start_date and activity_start_time and activity_start_time.date() < start_date: + if ( + start_date + and activity_start_time + and activity_start_time.date() < start_date + ): continue - if end_date and activity_start_time and activity_start_time.date() > end_date: + if ( + end_date + and activity_start_time + and activity_start_time.date() > end_date + ): continue activities_to_process.append(activity_data) @@ -187,7 +232,7 @@ class GarminActivityService: user_id=user_id, activity_id=str(activity_data.get("activityId")), 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}") else: 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( - job_id, - status="completed", - progress=1.0, - details={ - "synced_activities_count": synced_count, - "total_activities_found": total_activities - } - ) + await current_sync_job_manager.complete_sync() except Exception as e: - logger.error( - f"Error during activity synchronization for job {job_id}: {e}", exc_info=True - ) - job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e)) \ No newline at end of file + logger.error(f"Error during activity synchronization: {e}", exc_info=True) + await current_sync_job_manager.fail_sync(error_message=str(e)) diff --git a/backend/src/services/garmin_auth_service.py b/backend/src/services/garmin_auth_service.py index ec1b100..7ca86e5 100644 --- a/backend/src/services/garmin_auth_service.py +++ b/backend/src/services/garmin_auth_service.py @@ -1,9 +1,13 @@ import logging -import asyncio # Import asyncio for sleep from typing import Optional from garminconnect import Garmin -from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) from ..schemas import GarminCredentials @@ -13,34 +17,49 @@ logger = logging.getLogger(__name__) # Define a retry strategy for Garmin login GARMIN_LOGIN_RETRY_STRATEGY = retry( - stop=stop_after_attempt(10), # Increased attempts - wait=wait_exponential(multiplier=1, min=10, max=60), # Increased min and max wait times - retry=retry_if_exception_type(Exception), # Retry on any exception for now - reraise=True + stop=stop_after_attempt(10), # Increased attempts + wait=wait_exponential( + multiplier=1, min=10, max=60 + ), # Increased min and max wait times + retry=retry_if_exception_type(Exception), # Retry on any exception for now + reraise=True, ) + class GarminAuthService: def __init__(self): pass - @GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here + @GARMIN_LOGIN_RETRY_STRATEGY # Apply retry strategy here async def _perform_login(self, username: str, password: str) -> Garmin: """Helper to perform the actual garminconnect login with retry.""" client = Garmin(username, password) client.login() return client - async def initial_login(self, username: str, password: str) -> Optional[GarminCredentials]: + async def initial_login( + self, username: str, password: str + ) -> Optional[GarminCredentials]: """Performs initial login to Garmin Connect and returns GarminCredentials.""" try: - client = await self._perform_login(username, password) # Use the retried login helper + 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}") - return GarminCredentials( + # Extract tokens and cookies + garmin_credentials = GarminCredentials( 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: logger.error(f"Garmin initial login failed for {username}: {e}") - return None \ No newline at end of file + return None diff --git a/backend/src/services/garmin_client_service.py b/backend/src/services/garmin_client_service.py index 47f0069..cf246aa 100644 --- a/backend/src/services/garmin_client_service.py +++ b/backend/src/services/garmin_client_service.py @@ -1,11 +1,12 @@ import logging +from datetime import datetime # Import datetime from typing import Optional -from datetime import datetime # Import datetime from garminconnect import Garmin logger = logging.getLogger(__name__) + class GarminClientService: def __init__(self): self.client: Optional[Garmin] = None @@ -18,7 +19,7 @@ class GarminClientService: if self.username != username or self.password != password: self.username = username 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: """Authenticates with Garmin Connect using stored credentials, or reuses existing client.""" @@ -30,25 +31,37 @@ class GarminClientService: try: self.client = Garmin(self.username, self.password) 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 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 return False else: # If client exists, assume it's authenticated and check session validity if self.check_session_validity(): - logger.debug(f"Garmin client already authenticated for {self.username}. Reusing existing client.") + logger.debug( + f"Garmin client already authenticated for {self.username}. Reusing existing client." + ) return True else: - logger.info(f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login.") + logger.info( + f"Existing Garmin client session for {self.username} is invalid. Attempting to re-login." + ) try: - self.client.login() # Attempt to re-login with existing client - logger.info(f"Successfully re-logged in Garmin client for {self.username}.") + self.client.login() # Attempt to re-login with existing client + logger.info( + f"Successfully re-logged in Garmin client for {self.username}." + ) return True except Exception as e: - logger.error(f"Failed to re-login Garmin client for {self.username}: {e}") + logger.error( + f"Failed to re-login Garmin client for {self.username}: {e}" + ) self.client = None return False @@ -59,24 +72,27 @@ class GarminClientService: def get_client(self) -> Garmin: """Returns the authenticated Garmin client instance.""" - if self.client is None: # Check self.client directly - raise Exception("Garmin client not initialized or authenticated. Call authenticate first.") + if self.client is None: # Check self.client directly + raise Exception( + "Garmin client not initialized or authenticated. Call authenticate first." + ) return self.client def check_session_validity(self) -> bool: """ Checks if the current Garmin session is still valid by making a lightweight API call. """ - if self.client is None: # Check self.client directly + if self.client is None: # Check self.client directly return False 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}.") return True except Exception as e: logger.warning(f"Garmin session became invalid for {self.username}: {e}") - self.client = None # Invalidate client on session failure + self.client = None # Invalidate client on session failure return False + # Global instance for dependency injection garmin_client_service = GarminClientService() diff --git a/backend/src/services/garmin_health_service.py b/backend/src/services/garmin_health_service.py index bfa7e05..cfd6ed6 100644 --- a/backend/src/services/garmin_health_service.py +++ b/backend/src/services/garmin_health_service.py @@ -3,9 +3,13 @@ import logging from datetime import date, datetime, timedelta 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.garmin_auth_service import GarminAuthService from ..services.garmin_client_service import GarminClientService @@ -16,22 +20,25 @@ logger = logging.getLogger(__name__) GARMIN_RETRY_STRATEGY = retry( stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(Exception), # Broad exception for now, refine later - reraise=True + retry=retry_if_exception_type(Exception), # Broad exception for now, refine later + reraise=True, ) + class GarminHealthService: def __init__( self, garmin_client_service: GarminClientService, central_db_service: CentralDBService, - garmin_auth_service: GarminAuthService + garmin_auth_service: GarminAuthService, ): self.garmin_client_service = garmin_client_service self.central_db_service = central_db_service self.garmin_auth_service = garmin_auth_service - async def _get_authenticated_garmin_client(self, user_id: int) -> Optional[GarminClientService]: + async def _get_authenticated_garmin_client( + self, user_id: int + ) -> Optional[GarminClientService]: credentials = await self.central_db_service.get_garmin_credentials(user_id) if not credentials: logger.error(f"No Garmin credentials found for user {user_id}.") @@ -46,18 +53,29 @@ class GarminHealthService: # Check if the client is authenticated after updating credentials if not self.garmin_client_service.is_authenticated(): if not self.garmin_client_service.authenticate(): - logger.error(f"Failed to authenticate Garmin client for user {user_id}.") + logger.error( + f"Failed to authenticate Garmin client for user {user_id}." + ) return None return self.garmin_client_service @GARMIN_RETRY_STRATEGY async def download_and_save_health_metric( - self, metric_data: Dict[str, Any], - garmin_client: Optional[GarminClientService] = None # New argument + self, + metric_data: Dict[str, Any], + garmin_client: Optional[GarminClientService] = None, # New argument ) -> Optional[Dict[str, Any]]: - _garmin_client = garmin_client or await self._get_authenticated_garmin_client(user_id) + # 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: + logger.error( + "Garmin client not provided or authenticated for health metric download." + ) return None try: @@ -66,14 +84,18 @@ class GarminHealthService: value = metric_data.get("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 if isinstance(timestamp, str): try: timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) except ValueError: - logger.error(f"Invalid timestamp format for health metric: {timestamp}") + logger.error( + f"Invalid timestamp format for health metric: {timestamp}" + ) return None metric_data["timestamp"] = timestamp @@ -85,56 +107,71 @@ class GarminHealthService: except Exception as e: logger.error( f"Error downloading and saving health metric {metric_data}: {e}", - exc_info=True + exc_info=True, ) return None async def sync_health_metrics_in_background( self, - job_id: str, + user_id: int, + current_sync_job_manager, start_date: Optional[date] = None, end_date: Optional[date] = None, ): - user_id = 1 # Assuming single user for now try: - job_store.update_job(job_id, status="in_progress", progress=0.0) + # user_id = 1 # Assuming single user for now - now passed as argument # Authenticate Garmin client once at the beginning garmin_client = await self._get_authenticated_garmin_client(user_id) if not garmin_client: - raise Exception("Garmin client not authenticated 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) _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 = [ - 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 ] - daily_summaries = await asyncio.gather(*summary_tasks, return_exceptions=True) + daily_summaries = await asyncio.gather( + *summary_tasks, return_exceptions=True + ) all_metrics_data = [] for i, summary in enumerate(daily_summaries): 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 if summary: if "heartRate" in summary: - all_metrics_data.append({ - "type": "heart_rate", - "timestamp": summary["calendarDate"], - "value": summary["heartRate"].get("restingHeartRate"), - "unit": "bpm" - }) + all_metrics_data.append( + { + "type": "heart_rate", + "timestamp": summary["calendarDate"], + "value": summary["heartRate"].get("restingHeartRate"), + "unit": "bpm", + } + ) if "stress" in summary: - all_metrics_data.append({ - "type": "stress_score", - "timestamp": summary["calendarDate"], - "value": summary["stress"].get("overallStressLevel"), - "unit": "score" - }) + all_metrics_data.append( + { + "type": "stress_score", + "timestamp": summary["calendarDate"], + "value": summary["stress"].get("overallStressLevel"), + "unit": "score", + } + ) total_metrics = len(all_metrics_data) synced_count = 0 @@ -142,7 +179,7 @@ class GarminHealthService: metric_save_tasks = [ self.download_and_save_health_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 ] @@ -150,24 +187,19 @@ class GarminHealthService: for i, result in enumerate(results): 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: 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( - job_id, - status="completed", - progress=1.0, - details={ - "synced_health_metrics_count": synced_count, - "total_health_metrics_found": total_metrics - } - ) + await current_sync_job_manager.complete_sync() except Exception as e: logger.error( - f"Error during health metrics synchronization for job {job_id}: {e}", - exc_info=True + f"Error during health metrics synchronization: {e}", exc_info=True ) - job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e)) \ No newline at end of file + await current_sync_job_manager.fail_sync(error_message=str(e)) diff --git a/backend/src/services/garmin_workout_service.py b/backend/src/services/garmin_workout_service.py index f43c3fd..57a2135 100644 --- a/backend/src/services/garmin_workout_service.py +++ b/backend/src/services/garmin_workout_service.py @@ -2,9 +2,13 @@ import logging import uuid 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 logger = logging.getLogger(__name__) @@ -13,10 +17,11 @@ logger = logging.getLogger(__name__) GARMIN_RETRY_STRATEGY = retry( stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=4, max=10), - retry=retry_if_exception_type(Exception), # Broad exception for now, refine later - reraise=True + retry=retry_if_exception_type(Exception), # Broad exception for now, refine later + reraise=True, ) + class GarminWorkoutService: def __init__(self, garmin_client_service: GarminClientService): self.garmin_client_service = garmin_client_service @@ -27,8 +32,11 @@ class GarminWorkoutService: # Get workout from CentralDB from ..config import settings from .central_db_service import CentralDBService + central_db = CentralDBService(base_url=settings.CENTRAL_DB_URL) - workout = await central_db.get_workout_by_id(workout_id) # Assuming this method exists + workout = await central_db.get_workout_by_id( + workout_id + ) # Assuming this method exists if not workout: 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}) " "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 # await central_db.update_workout( @@ -68,34 +76,21 @@ class GarminWorkoutService: async def upload_workout_in_background( self, - job_id: str, + user_id: int, + current_sync_job_manager, workout_id: uuid.UUID, ): try: - job_store.update_job(job_id, status="in_progress", progress=0.0) uploaded_workout = await self.upload_workout(workout_id) if uploaded_workout: - job_store.update_job( - job_id, - status="completed", - progress=1.0, - details={ - "uploaded_workout_id": str(uploaded_workout.id), - "garmin_workout_id": uploaded_workout.garmin_workout_id - } - ) + await current_sync_job_manager.complete_sync() else: - job_store.update_job( - job_id, - status="failed", - progress=1.0, + await current_sync_job_manager.fail_sync( error_message=f"Failed to upload workout {workout_id}" ) except Exception as e: - logger.error( - f"Error during workout upload for job {job_id}: {e}", exc_info=True - ) - job_store.update_job(job_id, status="failed", progress=1.0, error_message=str(e)) + logger.error(f"Error during workout upload: {e}", exc_info=True) + await current_sync_job_manager.fail_sync(error_message=str(e)) diff --git a/backend/src/services/rate_limiter.py b/backend/src/services/rate_limiter.py index 5c18e1a..11d5b51 100644 --- a/backend/src/services/rate_limiter.py +++ b/backend/src/services/rate_limiter.py @@ -19,5 +19,5 @@ class RateLimiter: if not self.limiter.test(self.rate_limit_item, client_id): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Rate limit exceeded. Please try again later." + detail="Rate limit exceeded. Please try again later.", ) diff --git a/backend/src/services/sync_manager.py b/backend/src/services/sync_manager.py new file mode 100644 index 0000000..a8a00ae --- /dev/null +++ b/backend/src/services/sync_manager.py @@ -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() diff --git a/backend/src/services/sync_status_service.py b/backend/src/services/sync_status_service.py deleted file mode 100644 index 2b350b6..0000000 --- a/backend/src/services/sync_status_service.py +++ /dev/null @@ -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 [] diff --git a/backend/src/utils/file_utils.py b/backend/src/utils/file_utils.py index 5bdfd24..b718ba4 100644 --- a/backend/src/utils/file_utils.py +++ b/backend/src/utils/file_utils.py @@ -5,7 +5,7 @@ from pathlib import Path def calculate_sha256(file_path: Path) -> str: """Calculate the SHA256 checksum of a file.""" hasher = hashlib.sha256() - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: while True: chunk = f.read(8192) # Read in 8KB chunks if not chunk: diff --git a/backend/tests/api/test_garmin_auth_api.py b/backend/tests/api/test_garmin_auth_api.py index bbcee53..dd81230 100644 --- a/backend/tests/api/test_garmin_auth_api.py +++ b/backend/tests/api/test_garmin_auth_api.py @@ -2,25 +2,30 @@ from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch import pytest -from backend.src.main import app -from backend.src.schemas import GarminCredentials -from httpx import AsyncClient +from fastapi.testclient import TestClient + +from src.main import app +from src.schemas import GarminCredentials @pytest.fixture 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 yield service_instance + @pytest.fixture 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 yield service_instance + @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" password = "password123" @@ -29,28 +34,33 @@ async def test_garmin_login_success_new_credentials(mock_garmin_auth_service, mo garmin_password_plaintext=password, access_token="mock_access_token", 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.create_garmin_credentials.return_value = AsyncMock() # Simulate successful creation + mock_central_db_service.get_garmin_credentials.return_value = ( + 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: - response = await client.post( - "/api/garmin/login", - json={ - "username": username, - "password": password - } + with TestClient(app=app) as client: + response = client.post( + "/api/garmin/login", json={"username": username, "password": password} ) assert response.status_code == 200 assert response.json() == {"message": "Garmin account linked successfully."} 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() + @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" password = "password123" @@ -59,24 +69,22 @@ async def test_garmin_login_success_update_credentials(mock_garmin_auth_service, garmin_password_plaintext=password, access_token="mock_access_token_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( garmin_username=username, garmin_password_plaintext="old_password", access_token="old_access_token", access_token_secret="old_access_token_secret", - token_expiration_date=datetime.utcnow() - timedelta(hours=1) - ) # Existing credentials - mock_central_db_service.update_garmin_credentials.return_value = AsyncMock() # Simulate successful update + token_expiration_date=datetime.utcnow() - timedelta(hours=1), + ) # Existing credentials + mock_central_db_service.update_garmin_credentials.return_value = ( + AsyncMock() + ) # Simulate successful update - async with AsyncClient(app=app, base_url="http://test") as client: - response = await client.post( - "/api/garmin/login", - json={ - "username": username, - "password": password - } + with TestClient(app=app) as client: + response = client.post( + "/api/garmin/login", json={"username": username, "password": password} ) 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.update_garmin_credentials.assert_called_once() + @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" 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: - response = await client.post( - "/api/garmin/login", - json={ - "username": username, - "password": password - } + with TestClient(app=app) as client: + response = client.post( + "/api/garmin/login", json={"username": username, "password": password} ) 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.update_garmin_credentials.assert_not_called() + @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" password = "password123" @@ -118,25 +130,28 @@ async def test_garmin_login_failure_central_db_create_error(mock_garmin_auth_ser garmin_password_plaintext=password, access_token="mock_access_token", 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.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: - response = await client.post( - "/api/garmin/login", - json={ - "username": username, - "password": password - } + with TestClient(app=app) as client: + response = client.post( + "/api/garmin/login", json={"username": username, "password": password} ) 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 -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" password = "password123" @@ -145,25 +160,25 @@ async def test_garmin_login_failure_central_db_update_error(mock_garmin_auth_ser garmin_password_plaintext=password, access_token="mock_access_token_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( garmin_username=username, garmin_password_plaintext="old_password", access_token="old_access_token", 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: - response = await client.post( - "/api/garmin/login", - json={ - "username": username, - "password": password - } + with TestClient(app=app) as client: + response = client.post( + "/api/garmin/login", json={"username": username, "password": password} ) 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." + } diff --git a/backend/tests/api/test_garmin_sync_api.py b/backend/tests/api/test_garmin_sync_api.py index 2d989cb..4ea9a2e 100644 --- a/backend/tests/api/test_garmin_sync_api.py +++ b/backend/tests/api/test_garmin_sync_api.py @@ -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.schemas import User -from fastapi import HTTPException -from httpx import AsyncClient +from backend.src.services.sync_manager import current_sync_job_manager +from fastapi.testclient import TestClient + +client = TestClient(app) -@pytest.fixture -def mock_garmin_activity_service(): - with patch('backend.src.api.garmin_sync.GarminActivityService') as MockGarminActivityService: - service_instance = MockGarminActivityService.return_value - yield service_instance +def test_get_sync_status(): + response = client.get("/api/sync/garmin/sync/status") + assert response.status_code == 200 -@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 - response_json = response.json() - assert "job_id" in response_json - 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 + assert response.json() == { + "message": "Activity synchronization initiated successfully." + } -@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: - response = await client.post( - "/api/sync/garmin/activities", - json={} - ) +def test_trigger_activity_sync_conflict(): + # Manually start a sync to simulate a conflict + current_sync_job_manager._current_job = current_sync_job_manager.start_sync( + "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 - response_json = response.json() - assert "job_id" in response_json - 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 + assert response.json() == { + "message": "Workout synchronization initiated successfully." + } -@pytest.mark.asyncio -async def test_trigger_garmin_activity_sync_unauthorized(): - with patch('backend.src.api.garmin_sync.get_current_user', side_effect=HTTPException(status_code=401)): - async with AsyncClient(app=app, base_url="http://test") as client: - response = await client.post( - "/api/sync/garmin/activities", - json={} - ) - assert response.status_code == 401 - assert response.json() == {"detail": "Not Authenticated"} # Default FastAPI 401 detail + +def test_trigger_workout_sync_conflict(): + # Manually start a sync to simulate a conflict + current_sync_job_manager._current_job = current_sync_job_manager.start_sync( + "workouts" + ) + + response = client.post( + "/api/sync/garmin/workouts", + 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 diff --git a/backend/tests/integration/test_garmin_authentication_flow.py b/backend/tests/integration/test_garmin_authentication_flow.py index 29f74fe..545fb40 100644 --- a/backend/tests/integration/test_garmin_authentication_flow.py +++ b/backend/tests/integration/test_garmin_authentication_flow.py @@ -1,40 +1,44 @@ from unittest.mock import AsyncMock, patch import pytest -from backend.src.schemas import GarminCredentials -from backend.src.services.garmin_activity_service import GarminActivityService -from backend.src.services.garmin_health_service import GarminHealthService + +from src.schemas import GarminCredentials +from src.services.garmin_activity_service import GarminActivityService +from src.services.garmin_health_service import GarminHealthService @pytest.fixture def mock_garmin_auth_service_instance(): with patch( - 'backend.src.services.garmin_activity_service.GarminAuthService' + "src.services.garmin_activity_service.GarminAuthService" ) as MockGarminAuthService: instance = MockGarminAuthService.return_value yield instance + @pytest.fixture def mock_central_db_service_instance(): with patch( - 'backend.src.services.garmin_activity_service.CentralDBService' + "src.services.garmin_activity_service.CentralDBService" ) as MockCentralDBService: service_instance = MockCentralDBService.return_value yield service_instance + @pytest.fixture def mock_garmin_client_service_instance(): with patch( - 'backend.src.services.garmin_activity_service.GarminClientService' + "src.services.garmin_activity_service.GarminClientService" ) as MockGarminClientService: instance = MockGarminClientService.return_value yield instance + @pytest.mark.asyncio async def test_garmin_activity_sync_authentication_flow( mock_garmin_auth_service_instance, mock_central_db_service_instance, - mock_garmin_client_service_instance + mock_garmin_client_service_instance, ): user_id = 1 username = "test@example.com" @@ -42,10 +46,11 @@ async def test_garmin_activity_sync_authentication_flow( # Mock GarminCredentials from CentralDB mock_credentials = GarminCredentials( - garmin_username=username, - garmin_password_plaintext=password + garmin_username=username, 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_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_garmin_client_instance = AsyncMock() - mock_garmin_client_service_instance.get_client.return_value = mock_garmin_client_instance - mock_garmin_client_instance.get_activities.return_value = [] # Simulate no activities + mock_garmin_client_service_instance.get_client.return_value = ( + mock_garmin_client_instance + ) + mock_garmin_client_instance.get_activities.return_value = ( + [] + ) # Simulate no activities activity_service = GarminActivityService( garmin_client_service=mock_garmin_client_service_instance, - activity_download_service=AsyncMock(), # Mock this dependency - garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called - central_db_service=mock_central_db_service_instance + activity_download_service=AsyncMock(), # Mock this dependency + garmin_auth_service=mock_garmin_auth_service_instance, # Still needed for init, but methods not called + central_db_service=mock_central_db_service_instance, ) # 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 - mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(user_id) - mock_garmin_client_service_instance.update_credentials.assert_called_once_with(username, password) + mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with( + 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.authenticate.assert_called_once() mock_garmin_client_instance.get_activities.assert_called_once() + @pytest.mark.asyncio async def test_garmin_health_sync_authentication_flow( mock_garmin_auth_service_instance, mock_central_db_service_instance, - mock_garmin_client_service_instance + mock_garmin_client_service_instance, ): user_id = 1 username = "test@example.com" @@ -85,10 +99,11 @@ async def test_garmin_health_sync_authentication_flow( # Mock GarminCredentials from CentralDB mock_credentials = GarminCredentials( - garmin_username=username, - garmin_password_plaintext=password + garmin_username=username, 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_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_garmin_client_instance = AsyncMock() - mock_garmin_client_service_instance.get_client.return_value = mock_garmin_client_instance - mock_garmin_client_instance.get_daily_summary.return_value = [] # Simulate no summaries + mock_garmin_client_service_instance.get_client.return_value = ( + mock_garmin_client_instance + ) + mock_garmin_client_instance.get_daily_summary.return_value = ( + [] + ) # Simulate no summaries health_service = GarminHealthService( garmin_client_service=mock_garmin_client_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 - 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 - mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with(user_id) - mock_garmin_client_service_instance.update_credentials.assert_called_once_with(username, password) + mock_central_db_service_instance.get_garmin_credentials.assert_called_once_with( + 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.authenticate.assert_called_once() mock_garmin_client_instance.get_daily_summary.assert_called_once() diff --git a/backend/tests/unit/test_auth_service.py b/backend/tests/unit/test_auth_service.py index 0460a0f..0f400a6 100644 --- a/backend/tests/unit/test_auth_service.py +++ b/backend/tests/unit/test_auth_service.py @@ -10,7 +10,7 @@ from src.services.auth_service import AuthService @pytest.fixture def auth_service(): """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.get_user_by_email = AsyncMock() mock_central_db_instance.create_user = AsyncMock() @@ -21,21 +21,24 @@ def auth_service(): service.central_db = mock_central_db_instance yield service + @pytest.fixture def 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 + @pytest.fixture def mock_garth_client(): """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.refresh_token = "mock_refresh_token" mock_client.token_expires_at = 1234567890 yield mock_client + @pytest.mark.asyncio async def test_authenticate_garmin_connect_new_user_success( 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.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 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.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 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" password = "password123" 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( - 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 @@ -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.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 -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.""" email = "fail_garmin@example.com" password = "password123" @@ -125,6 +146,7 @@ async def test_authenticate_garmin_connect_garmin_failure(auth_service, mock_gar assert result is None + @pytest.mark.asyncio async def test_authenticate_garmin_connect_central_db_user_creation_failure( auth_service, mock_garth_login, mock_garth_client diff --git a/backend/tests/unit/test_garmin_auth_service.py b/backend/tests/unit/test_garmin_auth_service.py index aa4b121..1df04cb 100644 --- a/backend/tests/unit/test_garmin_auth_service.py +++ b/backend/tests/unit/test_garmin_auth_service.py @@ -2,25 +2,33 @@ from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch 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 def garmin_auth_service(): return GarminAuthService() + @pytest.mark.asyncio async def test_initial_login_success(garmin_auth_service): username = "test@example.com" 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.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_garth.Client.return_value.access_token = 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.access_token = ( + 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 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 credentials.token_expiration_date > datetime.utcnow() + @pytest.mark.asyncio async def test_initial_login_failure(garmin_auth_service): username = "invalid@example.com" 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.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) assert credentials is None + @pytest.mark.asyncio async def test_refresh_tokens_success(garmin_auth_service): credentials = GarminCredentials( @@ -53,14 +65,16 @@ async def test_refresh_tokens_success(garmin_auth_service): garmin_password_plaintext="password123", access_token="old_access_token", 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.reauthorize.return_value = None 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 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.garmin_username == credentials.garmin_username 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 refreshed_credentials.token_expiration_date > datetime.utcnow() + @pytest.mark.asyncio async def test_refresh_tokens_failure(garmin_auth_service): credentials = GarminCredentials( @@ -79,12 +96,14 @@ async def test_refresh_tokens_failure(garmin_auth_service): garmin_password_plaintext="invalid_password", access_token="old_access_token", 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.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) diff --git a/backend/tests/unit/test_rate_limiter.py b/backend/tests/unit/test_rate_limiter.py index b7173ac..2a5480c 100644 --- a/backend/tests/unit/test_rate_limiter.py +++ b/backend/tests/unit/test_rate_limiter.py @@ -18,6 +18,7 @@ async def test_rate_limiter_allows_requests_within_limit(): except HTTPException: pytest.fail("HTTPException raised unexpectedly.") + @pytest.mark.asyncio async def test_rate_limiter_raises_exception_when_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 the limiter.test method - 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 + 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 - 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: - 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 - 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 diff --git a/backend/tests/unit/test_sync_job.py b/backend/tests/unit/test_sync_job.py new file mode 100644 index 0000000..314a578 --- /dev/null +++ b/backend/tests/unit/test_sync_job.py @@ -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" diff --git a/backend/tests/unit/test_sync_manager.py b/backend/tests/unit/test_sync_manager.py new file mode 100644 index 0000000..00f32bd --- /dev/null +++ b/backend/tests/unit/test_sync_manager.py @@ -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" diff --git a/backend/tests/unit/test_sync_status_service.py b/backend/tests/unit/test_sync_status_service.py deleted file mode 100644 index b3cf364..0000000 --- a/backend/tests/unit/test_sync_status_service.py +++ /dev/null @@ -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) diff --git a/specs/004-home-sstent-projects/checklists/requirements.md b/specs/004-home-sstent-projects/checklists/requirements.md new file mode 100644 index 0000000..674f557 --- /dev/null +++ b/specs/004-home-sstent-projects/checklists/requirements.md @@ -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` \ No newline at end of file diff --git a/specs/004-home-sstent-projects/contracts/garmin_sync_status.json b/specs/004-home-sstent-projects/contracts/garmin_sync_status.json new file mode 100644 index 0000000..28a8ad9 --- /dev/null +++ b/specs/004-home-sstent-projects/contracts/garmin_sync_status.json @@ -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"] + } + } + } +} \ No newline at end of file diff --git a/specs/004-home-sstent-projects/data-model.md b/specs/004-home-sstent-projects/data-model.md new file mode 100644 index 0000000..f2d56c1 --- /dev/null +++ b/specs/004-home-sstent-projects/data-model.md @@ -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". diff --git a/specs/004-home-sstent-projects/plan.md b/specs/004-home-sstent-projects/plan.md new file mode 100644 index 0000000..f7e35c8 --- /dev/null +++ b/specs/004-home-sstent-projects/plan.md @@ -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 \ No newline at end of file diff --git a/specs/004-home-sstent-projects/quickstart.md b/specs/004-home-sstent-projects/quickstart.md new file mode 100644 index 0000000..ba97240 --- /dev/null +++ b/specs/004-home-sstent-projects/quickstart.md @@ -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. diff --git a/specs/004-home-sstent-projects/research.md b/specs/004-home-sstent-projects/research.md new file mode 100644 index 0000000..e69de29 diff --git a/specs/004-home-sstent-projects/spec.md b/specs/004-home-sstent-projects/spec.md new file mode 100644 index 0000000..b7f472a --- /dev/null +++ b/specs/004-home-sstent-projects/spec.md @@ -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. \ No newline at end of file diff --git a/specs/004-home-sstent-projects/tasks.md b/specs/004-home-sstent-projects/tasks.md new file mode 100644 index 0000000..a27168b --- /dev/null +++ b/specs/004-home-sstent-projects/tasks.md @@ -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.