feat: Implement single sync job management and progress tracking

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

View File

@@ -6,6 +6,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-10
- Python 3.13 + FastAPI, `garth`, `garminconnect`, `httpx`, `pydantic` (003-loginimprovements-use-the)
- Python 3.13 + FastAPI, garth, garminconnect, httpx, pydantic (003-loginimprovements-use-the)
- 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`

View File

@@ -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.")

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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):

View File

@@ -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,

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):

View File

@@ -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")

View File

@@ -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):

View File

@@ -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.

View File

@@ -10,62 +10,66 @@ from .services.garmin_auth_service import GarminAuthService # New import
from .services.garmin_client_service import GarminClientService, garmin_client_service
from .services.garmin_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

View File

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

View File

@@ -2,7 +2,7 @@ import logging
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
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.

View File

@@ -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."

View File

View File

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

View File

@@ -8,9 +8,11 @@ class UserBase(BaseModel):
name: str
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

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -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

View File

@@ -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:

View File

@@ -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))
logger.error(f"Error during activity synchronization: {e}", exc_info=True)
await current_sync_job_manager.fail_sync(error_message=str(e))

View File

@@ -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
return None

View File

@@ -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()

View File

@@ -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))
await current_sync_job_manager.fail_sync(error_message=str(e))

View File

@@ -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))

View File

@@ -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.",
)

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ from pathlib import Path
def calculate_sha256(file_path: Path) -> str:
"""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:

View File

@@ -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."
}

View File

@@ -1,80 +1,88 @@
from datetime import date
from unittest.mock import AsyncMock, patch
import pytest
from backend.src.main import app
from backend.src.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

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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