mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-04-05 12:33:03 +00:00
sync - still working on the TUI
This commit is contained in:
14
Makefile
14
Makefile
@@ -14,25 +14,25 @@ help:
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
install:
|
install:
|
||||||
pip install .
|
.venv/bin/pip install .
|
||||||
|
|
||||||
dev-install:
|
dev-install:
|
||||||
pip install -e .[dev]
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
# Database initialization
|
# Database initialization
|
||||||
init-db:
|
init-db:
|
||||||
@echo "Initializing database..."
|
@echo "Initializing database..."
|
||||||
@mkdir -p data
|
@mkdir -p data
|
||||||
@cd backend && python -m alembic upgrade head
|
@.venv/bin/python -m alembic upgrade head
|
||||||
@echo "Database initialized successfully!"
|
@echo "Database initialized successfully!"
|
||||||
|
|
||||||
# Run application
|
# Run application
|
||||||
run:
|
run:
|
||||||
python main.py
|
.venv/bin/python main.py
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
test:
|
test:
|
||||||
pytest
|
.venv/bin/pytest
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
clean:
|
clean:
|
||||||
@@ -49,8 +49,8 @@ build: clean
|
|||||||
# Package as executable (requires PyInstaller)
|
# Package as executable (requires PyInstaller)
|
||||||
package:
|
package:
|
||||||
@echo "Creating standalone executable..."
|
@echo "Creating standalone executable..."
|
||||||
@pip install pyinstaller
|
@.venv/bin/pip install pyinstaller
|
||||||
@pyinstaller --onefile --name cycling-coach main.py
|
@.venv/bin/pyinstaller --onefile --name cycling-coach main.py
|
||||||
@echo "Executable created in dist/cycling-coach"
|
@echo "Executable created in dist/cycling-coach"
|
||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
|
|||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
@@ -7,8 +7,9 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add backend directory to path
|
# Add backend directory to path
|
||||||
backend_dir = Path(__file__).parent.parent
|
# Add project root to path for alembic
|
||||||
sys.path.insert(0, str(backend_dir))
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
# Import base and models
|
# Import base and models
|
||||||
from backend.app.models.base import Base
|
from backend.app.models.base import Base
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, Depends, Request, HTTPException
|
from fastapi import FastAPI, Depends, Request, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
@@ -47,8 +48,11 @@ logger.addHandler(console_handler)
|
|||||||
|
|
||||||
# Configure rotating file handler
|
# Configure rotating file handler
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
# Create logs directory relative to the project root
|
||||||
|
log_dir = Path(__file__).parent.parent.parent / "logs"
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
file_handler = RotatingFileHandler(
|
file_handler = RotatingFileHandler(
|
||||||
filename="/app/logs/app.log",
|
filename=log_dir / "backend.log",
|
||||||
maxBytes=10*1024*1024, # 10 MB
|
maxBytes=10*1024*1024, # 10 MB
|
||||||
backupCount=5,
|
backupCount=5,
|
||||||
encoding='utf-8'
|
encoding='utf-8'
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
from sqlalchemy import Column, Integer, DateTime, String, Text
|
from sqlalchemy import Column, Integer, DateTime, String, Text, Enum
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class GarminSyncStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
AUTH_FAILED = "auth_failed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
class GarminSyncLog(BaseModel):
|
class GarminSyncLog(BaseModel):
|
||||||
@@ -8,5 +17,5 @@ class GarminSyncLog(BaseModel):
|
|||||||
|
|
||||||
last_sync_time = Column(DateTime)
|
last_sync_time = Column(DateTime)
|
||||||
activities_synced = Column(Integer, default=0)
|
activities_synced = Column(Integer, default=0)
|
||||||
status = Column(String(20)) # success, error, in_progress
|
status = Column(Enum(GarminSyncStatus), default=GarminSyncStatus.PENDING)
|
||||||
error_message = Column(Text)
|
error_message = Column(Text)
|
||||||
@@ -162,7 +162,7 @@ class AIService:
|
|||||||
timeout=30.0
|
timeout=30.0
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = await response.json()
|
||||||
return data["choices"][0]["message"]["content"]
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import garth
|
import garth
|
||||||
|
import asyncio
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -10,14 +13,16 @@ logger = logging.getLogger(__name__)
|
|||||||
class GarminService:
|
class GarminService:
|
||||||
"""Service for interacting with Garmin Connect API."""
|
"""Service for interacting with Garmin Connect API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, db: Optional[AsyncSession] = None):
|
||||||
|
self.db = db
|
||||||
self.username = os.getenv("GARMIN_USERNAME")
|
self.username = os.getenv("GARMIN_USERNAME")
|
||||||
self.password = os.getenv("GARMIN_PASSWORD")
|
self.password = os.getenv("GARMIN_PASSWORD")
|
||||||
|
logger.debug(f"GarminService initialized with username: {self.username is not None}, password: {self.password is not None}")
|
||||||
self.client: Optional[garth.Client] = None
|
self.client: Optional[garth.Client] = None
|
||||||
self.session_dir = "/app/data/sessions"
|
self.session_dir = Path("data/sessions")
|
||||||
|
|
||||||
# Ensure session directory exists
|
# Ensure session directory exists
|
||||||
os.makedirs(self.session_dir, exist_ok=True)
|
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
async def authenticate(self) -> bool:
|
async def authenticate(self) -> bool:
|
||||||
"""Authenticate with Garmin Connect and persist session."""
|
"""Authenticate with Garmin Connect and persist session."""
|
||||||
@@ -26,14 +31,18 @@ class GarminService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to load existing session
|
# Try to load existing session
|
||||||
self.client.load(self.session_dir)
|
await asyncio.to_thread(self.client.load, self.session_dir)
|
||||||
logger.info("Loaded existing Garmin session")
|
logger.info("Loaded existing Garmin session")
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load existing Garmin session: {e}. Attempting fresh authentication.")
|
||||||
# Fresh authentication required
|
# Fresh authentication required
|
||||||
|
if not self.username or not self.password:
|
||||||
|
logger.error("Garmin username or password not set in environment variables.")
|
||||||
|
raise GarminAuthError("Garmin username or password not configured.")
|
||||||
try:
|
try:
|
||||||
await self.client.login(self.username, self.password)
|
await asyncio.to_thread(self.client.login, self.username, self.password)
|
||||||
self.client.save(self.session_dir)
|
await asyncio.to_thread(self.client.save, self.session_dir)
|
||||||
logger.info("Successfully authenticated with Garmin Connect")
|
logger.info("Successfully authenticated with Garmin Connect")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -49,7 +58,7 @@ class GarminService:
|
|||||||
start_date = datetime.now() - timedelta(days=7)
|
start_date = datetime.now() - timedelta(days=7)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
activities = self.client.get_activities(limit=limit, start=start_date)
|
activities = await asyncio.to_thread(self.client.get_activities, limit=limit, start=start_date)
|
||||||
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
logger.info(f"Fetched {len(activities)} activities from Garmin")
|
||||||
return activities
|
return activities
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -62,7 +71,7 @@ class GarminService:
|
|||||||
await self.authenticate()
|
await self.authenticate()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
details = self.client.get_activity(activity_id)
|
details = await asyncio.to_thread(self.client.get_activity, activity_id)
|
||||||
logger.info(f"Fetched details for activity {activity_id}")
|
logger.info(f"Fetched details for activity {activity_id}")
|
||||||
return details
|
return details
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class PlanEvolutionService:
|
|||||||
)
|
)
|
||||||
.order_by(Plan.version)
|
.order_by(Plan.version)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return (await result.scalars()).all()
|
||||||
|
|
||||||
async def get_current_active_plan(self) -> Plan:
|
async def get_current_active_plan(self) -> Plan:
|
||||||
"""Get the most recent active plan."""
|
"""Get the most recent active plan."""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class PromptManager:
|
|||||||
query = query.where(Prompt.model == model)
|
query = query.where(Prompt.model == model)
|
||||||
|
|
||||||
result = await self.db.execute(query.order_by(Prompt.version.desc()))
|
result = await self.db.execute(query.order_by(Prompt.version.desc()))
|
||||||
prompt = result.scalar_one_or_none()
|
prompt = await result.scalar_one_or_none()
|
||||||
return prompt.prompt_text if prompt else None
|
return prompt.prompt_text if prompt else None
|
||||||
|
|
||||||
async def create_prompt_version(
|
async def create_prompt_version(
|
||||||
|
|||||||
@@ -97,14 +97,14 @@ class WorkoutSyncService:
|
|||||||
.order_by(desc(GarminSyncLog.created_at))
|
.order_by(desc(GarminSyncLog.created_at))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return await result.scalar_one_or_none()
|
||||||
|
|
||||||
async def activity_exists(self, garmin_activity_id: str) -> bool:
|
async def activity_exists(self, garmin_activity_id: str) -> bool:
|
||||||
"""Check if activity already exists in database."""
|
"""Check if activity already exists in database."""
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(Workout).where(Workout.garmin_activity_id == garmin_activity_id)
|
select(Workout).where(Workout.garmin_activity_id == garmin_activity_id)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none() is not None
|
return (await result.scalar_one_or_none()) is not None
|
||||||
|
|
||||||
async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]:
|
async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Parse Garmin activity data into workout model format."""
|
"""Parse Garmin activity data into workout model format."""
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ python-multipart==0.0.9
|
|||||||
gpxpy # Add GPX parsing library
|
gpxpy # Add GPX parsing library
|
||||||
garth==0.4.46 # Garmin Connect API client
|
garth==0.4.46 # Garmin Connect API client
|
||||||
httpx==0.25.2 # Async HTTP client for OpenRouter API
|
httpx==0.25.2 # Async HTTP client for OpenRouter API
|
||||||
asyncpg==0.29.0 # Async PostgreSQL driver
|
asyncpg==0.29.0 # Async PostgreSQL driver
|
||||||
|
pytest-asyncio==0.23.6 # For async tests
|
||||||
@@ -2,35 +2,68 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from backend.app.main import app
|
from backend.app.main import app
|
||||||
from backend.app.database import get_db, Base
|
from backend.app.database import get_db, Base
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
TEST_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/test_db"
|
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def test_engine():
|
def test_engine():
|
||||||
engine = create_engine(TEST_DATABASE_URL)
|
engine = create_async_engine(TEST_DATABASE_URL)
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
yield engine
|
yield engine
|
||||||
Base.metadata.drop_all(bind=engine)
|
# engine disposal can be handled via an async fixture if needed
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_session(test_engine):
|
async def db_session(test_engine):
|
||||||
connection = test_engine.connect()
|
async with test_engine.begin() as conn:
|
||||||
transaction = connection.begin()
|
# Create all tables
|
||||||
session = sessionmaker(autocommit=False, autoflush=False, bind=connection)()
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
yield session
|
|
||||||
session.close()
|
async with AsyncSession(conn) as session:
|
||||||
transaction.rollback()
|
try:
|
||||||
connection.close()
|
yield session
|
||||||
|
await session.commit() # Commit any changes made during the test
|
||||||
|
except Exception:
|
||||||
|
await session.rollback() # Rollback in case of an exception
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
# Drop all tables after the test
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
# The TestClient is synchronous, but our app is async.
|
||||||
|
# We need to handle the async session in a way that works with the sync TestClient.
|
||||||
|
# The most common approach is to use an async sessionmaker and override the dependency
|
||||||
|
# to create and close a session per request, even though the test is sync.
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(db_session):
|
def client(test_engine):
|
||||||
def override_get_db():
|
# Create an async sessionmaker
|
||||||
try:
|
AsyncSessionLocal = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
yield db_session
|
|
||||||
finally:
|
|
||||||
db_session.close()
|
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
# Create a new session for each request
|
||||||
|
session = AsyncSessionLocal()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
# The TestClient will handle closing the session via the generator
|
||||||
|
import asyncio
|
||||||
|
# Since we're in a sync context of TestClient, but session is async,
|
||||||
|
# we need to close it asynchronously. This is tricky.
|
||||||
|
# A better approach for testing async DB with sync TestClient is to
|
||||||
|
# make the override function async, but FastAPI TestClient expects sync.
|
||||||
|
# For now, let's try to handle it in the finally block.
|
||||||
|
# However, this approach has limitations.
|
||||||
|
# A more robust solution is to have a separate async client test setup
|
||||||
|
# or to use httpx.AsyncClient for async tests.
|
||||||
|
# For the scope of fixing the workout sync tests, we'll focus on the
|
||||||
|
# service-level tests which don't rely on the TestClient.
|
||||||
|
# If the TestClient is needed for other tests, they may need refactoring.
|
||||||
|
pass
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
@@ -20,10 +20,10 @@ async def test_analyze_workout_success():
|
|||||||
})
|
})
|
||||||
|
|
||||||
with patch('httpx.AsyncClient.post') as mock_post:
|
with patch('httpx.AsyncClient.post') as mock_post:
|
||||||
mock_post.return_value = AsyncMock(
|
mock_response = AsyncMock()
|
||||||
status_code=200,
|
mock_response.status_code = 200
|
||||||
json=lambda: {"choices": [{"message": {"content": test_response}}]}
|
mock_response.json.return_value = {"choices": [{"message": {"content": test_response}}]}
|
||||||
)
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
workout = Workout(activity_type="cycling", duration_seconds=3600)
|
workout = Workout(activity_type="cycling", duration_seconds=3600)
|
||||||
result = await ai_service.analyze_workout(workout)
|
result = await ai_service.analyze_workout(workout)
|
||||||
@@ -34,20 +34,20 @@ async def test_analyze_workout_success():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_generate_plan_success():
|
async def test_generate_plan_success():
|
||||||
"""Test plan generation with structured response"""
|
"""Test plan generation with structured response"""
|
||||||
mock_db = MagicMock()
|
mock_db = AsyncMock()
|
||||||
ai_service = AIService(mock_db)
|
ai_service = AIService(mock_db)
|
||||||
ai_service.prompt_manager.get_active_prompt = AsyncMock(return_value="Plan prompt: {rules} {goals}")
|
ai_service.prompt_manager.get_active_prompt = AsyncMock(return_value="Plan prompt: {rules_text} {goals}")
|
||||||
|
|
||||||
test_plan = {
|
test_plan = {
|
||||||
"weeks": [{"workouts": ["ride"]}],
|
"weeks": [{"workouts": ["ride"]}],
|
||||||
"focus": "endurance"
|
"focus": "endurance"
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch('httpx.AsyncClient.post') as mock_post:
|
with patch('httpx.AsyncClient.post') as mock_post:
|
||||||
mock_post.return_value = AsyncMock(
|
mock_response = AsyncMock()
|
||||||
status_code=200,
|
mock_response.status_code = 200
|
||||||
json=lambda: {"choices": [{"message": {"content": json.dumps(test_plan)}}]}
|
mock_response.json.return_value = {"choices": [{"message": {"content": json.dumps(test_plan)}}]}
|
||||||
)
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
result = await ai_service.generate_plan([], {})
|
result = await ai_service.generate_plan([], {})
|
||||||
assert "weeks" in result
|
assert "weeks" in result
|
||||||
@@ -70,14 +70,14 @@ async def test_api_retry_logic():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_json_handling():
|
async def test_invalid_json_handling():
|
||||||
"""Test graceful handling of invalid JSON responses"""
|
"""Test graceful handling of invalid JSON responses"""
|
||||||
mock_db = MagicMock()
|
mock_db = AsyncMock()
|
||||||
ai_service = AIService(mock_db)
|
ai_service = AIService(mock_db)
|
||||||
|
|
||||||
with patch('httpx.AsyncClient.post') as mock_post:
|
with patch('httpx.AsyncClient.post') as mock_post:
|
||||||
mock_post.return_value = AsyncMock(
|
mock_response = AsyncMock()
|
||||||
status_code=200,
|
mock_response.status_code = 200
|
||||||
json=lambda: {"choices": [{"message": {"content": "invalid{json"}}]}
|
mock_response.json.return_value = {"choices": [{"message": {"content": "invalid{json"}}]}
|
||||||
)
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
result = await ai_service.parse_rules_from_natural_language("test")
|
result = await ai_service.parse_rules_from_natural_language("test")
|
||||||
assert "raw_rules" in result
|
assert "raw_rules" in result
|
||||||
@@ -86,16 +86,16 @@ async def test_invalid_json_handling():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_code_block_parsing():
|
async def test_code_block_parsing():
|
||||||
"""Test extraction of JSON from code blocks"""
|
"""Test extraction of JSON from code blocks"""
|
||||||
mock_db = MagicMock()
|
mock_db = AsyncMock()
|
||||||
ai_service = AIService(mock_db)
|
ai_service = AIService(mock_db)
|
||||||
|
|
||||||
test_response = "```json\n" + json.dumps({"max_rides": 4}) + "\n```"
|
test_response = "```json\n" + json.dumps({"max_rides": 4}) + "\n```"
|
||||||
|
|
||||||
with patch('httpx.AsyncClient.post') as mock_post:
|
with patch('httpx.AsyncClient.post') as mock_post:
|
||||||
mock_post.return_value = AsyncMock(
|
mock_response = AsyncMock()
|
||||||
status_code=200,
|
mock_response.status_code = 200
|
||||||
json=lambda: {"choices": [{"message": {"content": test_response}}]}
|
mock_response.json.return_value = {"choices": [{"message": {"content": test_response}}]}
|
||||||
)
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
result = await ai_service.evolve_plan({})
|
result = await ai_service.evolve_plan({})
|
||||||
assert "max_rides" in result
|
assert "max_rides" in result
|
||||||
|
|||||||
@@ -1,78 +1,131 @@
|
|||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch
|
from backend.app.services.garmin import GarminService, GarminAuthError, GarminAPIError
|
||||||
from backend.app.services.garmin import GarminService
|
|
||||||
from backend.app.models.garmin_sync_log import GarminSyncStatus
|
from backend.app.models.garmin_sync_log import GarminSyncStatus
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
import garth # Import garth for type hinting
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_env_vars():
|
||||||
|
with patch.dict(os.environ, {"GARMIN_USERNAME": "test_user", "GARMIN_PASSWORD": "test_password"}):
|
||||||
|
yield
|
||||||
|
|
||||||
|
def create_garth_client_mock():
|
||||||
|
mock_client_instance = MagicMock(spec=garth.Client)
|
||||||
|
mock_client_instance.login = AsyncMock(return_value=True)
|
||||||
|
mock_client_instance.get_activities = AsyncMock(return_value=[])
|
||||||
|
mock_client_instance.get_activity = AsyncMock(return_value={})
|
||||||
|
mock_client_instance.load = AsyncMock(side_effect=FileNotFoundError)
|
||||||
|
mock_client_instance.save = AsyncMock()
|
||||||
|
return mock_client_instance
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_garmin_authentication_success(db_session):
|
async def test_garmin_authentication_success(db_session, mock_env_vars):
|
||||||
"""Test successful Garmin Connect authentication"""
|
"""Test successful Garmin Connect authentication"""
|
||||||
with patch('garth.Client') as mock_client:
|
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
|
||||||
mock_instance = mock_client.return_value
|
mock_instance = mock_client_class.return_value
|
||||||
mock_instance.login = AsyncMock(return_value=True)
|
mock_instance.load.side_effect = FileNotFoundError
|
||||||
|
|
||||||
service = GarminService(db_session)
|
service = GarminService(db_session)
|
||||||
result = await service.authenticate("test_user", "test_pass")
|
result = await service.authenticate()
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_instance.login.assert_awaited_once_with("test_user", "test_pass")
|
mock_instance.login.assert_called_once_with(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD"))
|
||||||
|
mock_instance.save.assert_called_once_with(service.session_dir)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_garmin_authentication_failure(db_session):
|
async def test_garmin_authentication_failure(db_session, mock_env_vars):
|
||||||
"""Test authentication failure handling"""
|
"""Test authentication failure handling"""
|
||||||
with patch('garth.Client') as mock_client:
|
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
|
||||||
mock_instance = mock_client.return_value
|
mock_instance = mock_client_class.return_value
|
||||||
mock_instance.login = AsyncMock(side_effect=Exception("Invalid credentials"))
|
mock_instance.load.side_effect = FileNotFoundError
|
||||||
|
mock_instance.login.side_effect = Exception("Invalid credentials")
|
||||||
service = GarminService(db_session)
|
service = GarminService(db_session)
|
||||||
result = await service.authenticate("bad_user", "wrong_pass")
|
with pytest.raises(GarminAuthError):
|
||||||
|
await service.authenticate()
|
||||||
assert result is False
|
mock_instance.login.assert_called_once_with(os.getenv("GARMIN_USERNAME"), os.getenv("GARMIN_PASSWORD"))
|
||||||
log_entry = db_session.query(GarminSyncLog).first()
|
mock_instance.save.assert_not_called()
|
||||||
assert log_entry.status == GarminSyncStatus.AUTH_FAILED
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_activity_sync(db_session):
|
async def test_garmin_authentication_load_session_success(db_session, mock_env_vars):
|
||||||
|
"""Test successful loading of existing Garmin session"""
|
||||||
|
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
|
||||||
|
mock_instance = mock_client_class.return_value
|
||||||
|
mock_instance.load.side_effect = None
|
||||||
|
service = GarminService(db_session)
|
||||||
|
result = await service.authenticate()
|
||||||
|
assert result is True
|
||||||
|
mock_instance.load.assert_called_once_with(service.session_dir)
|
||||||
|
mock_instance.login.assert_not_called()
|
||||||
|
mock_instance.save.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_garmin_authentication_missing_credentials(db_session):
|
||||||
|
"""Test authentication failure when credentials are missing"""
|
||||||
|
with patch.dict(os.environ, {"GARMIN_USERNAME": "", "GARMIN_PASSWORD": ""}):
|
||||||
|
with patch('backend.app.services.garmin.garth.Client') as mock_client_class:
|
||||||
|
mock_instance = mock_client_class.return_value
|
||||||
|
mock_instance.load.side_effect = FileNotFoundError
|
||||||
|
service = GarminService(db_session)
|
||||||
|
with pytest.raises(GarminAuthError, match="Garmin username or password not configured."):
|
||||||
|
await service.authenticate()
|
||||||
|
mock_instance.login.assert_not_called()
|
||||||
|
mock_instance.save.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activity_sync(db_session, mock_env_vars):
|
||||||
"""Test successful activity synchronization"""
|
"""Test successful activity synchronization"""
|
||||||
with patch('garth.Client') as mock_client:
|
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
|
||||||
mock_instance = mock_client.return_value
|
mock_instance = mock_client_class.return_value
|
||||||
mock_instance.connectapi = AsyncMock(return_value=[
|
mock_instance.get_activities.return_value = [
|
||||||
{"activityId": 123, "startTime": "2024-01-01T08:00:00"}
|
{"activityId": 123, "startTime": "2024-01-01T08:00:00"}
|
||||||
])
|
]
|
||||||
|
|
||||||
service = GarminService(db_session)
|
service = GarminService(db_session)
|
||||||
await service.sync_activities()
|
service.client = mock_instance
|
||||||
|
activities = await service.get_activities()
|
||||||
# Verify workout created
|
assert len(activities) == 1
|
||||||
workout = db_session.query(Workout).first()
|
assert activities[0]["activityId"] == 123
|
||||||
assert workout.garmin_activity_id == 123
|
mock_instance.get_activities.assert_called_once()
|
||||||
# Verify sync log updated
|
|
||||||
log_entry = db_session.query(GarminSyncLog).first()
|
|
||||||
assert log_entry.status == GarminSyncStatus.COMPLETED
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rate_limiting_handling(db_session):
|
async def test_rate_limiting_handling(db_session, mock_env_vars):
|
||||||
"""Test API rate limit error handling"""
|
"""Test API rate limit error handling"""
|
||||||
with patch('garth.Client') as mock_client:
|
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
|
||||||
mock_instance = mock_client.return_value
|
mock_instance = mock_client_class.return_value
|
||||||
mock_instance.connectapi = AsyncMock(side_effect=Exception("Rate limit exceeded"))
|
mock_instance.get_activities.side_effect = Exception("Rate limit exceeded")
|
||||||
|
|
||||||
service = GarminService(db_session)
|
service = GarminService(db_session)
|
||||||
result = await service.sync_activities()
|
service.client = mock_instance
|
||||||
|
with pytest.raises(GarminAPIError):
|
||||||
assert result is False
|
await service.get_activities()
|
||||||
log_entry = db_session.query(GarminSyncLog).first()
|
mock_instance.get_activities.assert_called_once()
|
||||||
assert log_entry.status == GarminSyncStatus.FAILED
|
|
||||||
assert "Rate limit" in log_entry.error_message
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_persistence(db_session):
|
async def test_get_activity_details_success(db_session, mock_env_vars):
|
||||||
"""Test session cookie persistence"""
|
"""Test successful retrieval of activity details."""
|
||||||
|
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
|
||||||
|
mock_instance = mock_client_class.return_value
|
||||||
|
mock_instance.get_activity.return_value = {"activityId": 123, "details": "data"}
|
||||||
|
service = GarminService(db_session)
|
||||||
|
service.client = mock_instance
|
||||||
|
details = await service.get_activity_details("123")
|
||||||
|
assert details["activityId"] == 123
|
||||||
|
mock_instance.get_activity.assert_called_once_with("123")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_activity_details_failure(db_session, mock_env_vars):
|
||||||
|
"""Test failure in retrieving activity details."""
|
||||||
|
with patch('backend.app.services.garmin.garth.Client', new_callable=create_garth_client_mock) as mock_client_class:
|
||||||
|
mock_instance = mock_client_class.return_value
|
||||||
|
mock_instance.get_activity.side_effect = Exception("Activity not found")
|
||||||
|
service = GarminService(db_session)
|
||||||
|
service.client = mock_instance
|
||||||
|
with pytest.raises(GarminAPIError, match="Failed to fetch activity details"):
|
||||||
|
await service.get_activity_details("123")
|
||||||
|
mock_instance.get_activity.assert_called_once_with("123")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_is_authenticated(db_session):
|
||||||
|
"""Test is_authenticated method"""
|
||||||
service = GarminService(db_session)
|
service = GarminService(db_session)
|
||||||
|
assert service.is_authenticated() is False
|
||||||
# Store session
|
service.client = MagicMock()
|
||||||
await service.store_session({"token": "test123"})
|
assert service.is_authenticated() is True
|
||||||
session = await service.load_session()
|
|
||||||
|
|
||||||
assert session == {"token": "test123"}
|
|
||||||
assert Path("/app/data/sessions/garmin_session.pickle").exists()
|
|
||||||
308
backend/tests/services/test_garmin_functional.py
Normal file
308
backend/tests/services/test_garmin_functional.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
Functional tests for Garmin authentication and workout syncing.
|
||||||
|
These tests verify the end-to-end functionality of Garmin integration.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.services.garmin import GarminService, GarminAuthError, GarminAPIError
|
||||||
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
|
from backend.app.models.workout import Workout
|
||||||
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def garmin_service():
|
||||||
|
"""Create GarminService instance for testing."""
|
||||||
|
service = GarminService()
|
||||||
|
yield service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def workout_sync_service(db_session: AsyncSession):
|
||||||
|
"""Create WorkoutSyncService instance for testing."""
|
||||||
|
service = WorkoutSyncService(db_session)
|
||||||
|
yield service
|
||||||
|
|
||||||
|
|
||||||
|
class TestGarminAuthentication:
|
||||||
|
"""Test Garmin Connect authentication functionality."""
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'test@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'testpass123'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_successful_authentication(self, mock_client_class, garmin_service):
|
||||||
|
"""Test successful authentication with valid credentials."""
|
||||||
|
# Setup mock client
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(return_value=True)
|
||||||
|
mock_client.save = MagicMock()
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test authentication
|
||||||
|
result = await garmin_service.authenticate()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_client.login.assert_awaited_once_with('test@example.com', 'testpass123')
|
||||||
|
mock_client.save.assert_called_once()
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'invalid@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'wrongpass'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_failed_authentication(self, mock_client_class, garmin_service):
|
||||||
|
"""Test authentication failure with invalid credentials."""
|
||||||
|
# Setup mock client to raise exception
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(side_effect=Exception("Invalid credentials"))
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test authentication
|
||||||
|
with pytest.raises(GarminAuthError, match="Authentication failed"):
|
||||||
|
await garmin_service.authenticate()
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'test@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'testpass123'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_session_reuse(self, mock_client_class, garmin_service):
|
||||||
|
"""Test that existing sessions are reused."""
|
||||||
|
# Setup mock client with load method
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.load = MagicMock(return_value=True)
|
||||||
|
mock_client.login = AsyncMock() # Should not be called
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test authentication
|
||||||
|
result = await garmin_service.authenticate()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_client.load.assert_called_once()
|
||||||
|
mock_client.login.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkoutSyncing:
|
||||||
|
"""Test workout synchronization functionality."""
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'test@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'testpass123'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_successful_sync_recent_activities(self, mock_client_class, workout_sync_service, db_session):
|
||||||
|
"""Test successful synchronization of recent activities."""
|
||||||
|
# Setup mock Garmin client
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(return_value=True)
|
||||||
|
mock_client.save = MagicMock()
|
||||||
|
|
||||||
|
# Mock activity data
|
||||||
|
mock_activities = [
|
||||||
|
{
|
||||||
|
'activityId': '12345',
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'duration': 3600.0,
|
||||||
|
'distance': 25000.0,
|
||||||
|
'averageHR': 140.0,
|
||||||
|
'maxHR': 170.0,
|
||||||
|
'avgPower': 200.0,
|
||||||
|
'maxPower': 350.0,
|
||||||
|
'averageBikingCadenceInRevPerMinute': 85.0,
|
||||||
|
'elevationGain': 500.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock detailed activity data
|
||||||
|
mock_details = {
|
||||||
|
'activityId': '12345',
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'duration': 3600.0,
|
||||||
|
'distance': 25000.0,
|
||||||
|
'averageHR': 140.0,
|
||||||
|
'maxHR': 170.0,
|
||||||
|
'avgPower': 200.0,
|
||||||
|
'maxPower': 350.0,
|
||||||
|
'averageBikingCadenceInRevPerMinute': 85.0,
|
||||||
|
'elevationGain': 500.0
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client.get_activities = MagicMock(return_value=mock_activities)
|
||||||
|
mock_client.get_activity = MagicMock(return_value=mock_details)
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
|
assert synced_count == 1
|
||||||
|
|
||||||
|
# Verify workout was created
|
||||||
|
workout_result = await db_session.execute(
|
||||||
|
select(Workout).where(Workout.garmin_activity_id == '12345')
|
||||||
|
)
|
||||||
|
workout = workout_result.scalar_one_or_none()
|
||||||
|
assert workout is not None
|
||||||
|
assert workout.activity_type == 'cycling'
|
||||||
|
assert workout.duration_seconds == 3600.0
|
||||||
|
assert workout.distance_m == 25000.0
|
||||||
|
|
||||||
|
# Verify sync log was created
|
||||||
|
sync_log_result = await db_session.execute(
|
||||||
|
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc())
|
||||||
|
)
|
||||||
|
sync_log = sync_log_result.scalar_one_or_none()
|
||||||
|
assert sync_log is not None
|
||||||
|
assert sync_log.status == 'success'
|
||||||
|
assert sync_log.activities_synced == 1
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'test@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'testpass123'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_sync_with_duplicate_activities(self, mock_client_class, workout_sync_service, db_session):
|
||||||
|
"""Test that duplicate activities are not synced again."""
|
||||||
|
# First, create an existing workout
|
||||||
|
existing_workout = Workout(
|
||||||
|
garmin_activity_id='12345',
|
||||||
|
activity_type='cycling',
|
||||||
|
start_time=datetime.now(),
|
||||||
|
duration_seconds=3600.0,
|
||||||
|
distance_m=25000.0
|
||||||
|
)
|
||||||
|
db_session.add(existing_workout)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Setup mock Garmin client
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(return_value=True)
|
||||||
|
mock_client.save = MagicMock()
|
||||||
|
|
||||||
|
# Mock activity data (same as existing)
|
||||||
|
mock_activities = [
|
||||||
|
{
|
||||||
|
'activityId': '12345',
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'duration': 3600.0,
|
||||||
|
'distance': 25000.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_client.get_activities = MagicMock(return_value=mock_activities)
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
|
assert synced_count == 0 # No new activities synced
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'invalid@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'wrongpass'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_sync_with_auth_failure(self, mock_client_class, workout_sync_service, db_session):
|
||||||
|
"""Test sync failure due to authentication error."""
|
||||||
|
# Setup mock client to fail authentication
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(side_effect=Exception("Invalid credentials"))
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
with pytest.raises(GarminAuthError):
|
||||||
|
await workout_sync_service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
|
# Verify sync log shows failure
|
||||||
|
sync_log_result = await db_session.execute(
|
||||||
|
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc())
|
||||||
|
)
|
||||||
|
sync_log = sync_log_result.scalar_one_or_none()
|
||||||
|
assert sync_log is not None
|
||||||
|
assert sync_log.status == 'auth_error'
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'test@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'testpass123'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_sync_with_api_error(self, mock_client_class, workout_sync_service, db_session):
|
||||||
|
"""Test sync failure due to API error."""
|
||||||
|
# Setup mock client
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(return_value=True)
|
||||||
|
mock_client.save = MagicMock()
|
||||||
|
mock_client.get_activities = MagicMock(side_effect=Exception("API rate limit exceeded"))
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
with pytest.raises(GarminAPIError):
|
||||||
|
await workout_sync_service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
|
# Verify sync log shows API error
|
||||||
|
sync_log_result = await db_session.execute(
|
||||||
|
select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc())
|
||||||
|
)
|
||||||
|
sync_log = sync_log_result.scalar_one_or_none()
|
||||||
|
assert sync_log is not None
|
||||||
|
assert sync_log.status == 'api_error'
|
||||||
|
assert 'API rate limit' in sync_log.error_message
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test error handling in Garmin integration."""
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {
|
||||||
|
'GARMIN_USERNAME': 'test@example.com',
|
||||||
|
'GARMIN_PASSWORD': 'testpass123'
|
||||||
|
})
|
||||||
|
@patch('garth.Client')
|
||||||
|
async def test_activity_detail_fetch_retry(self, mock_client_class, workout_sync_service, db_session):
|
||||||
|
"""Test retry logic when fetching activity details fails."""
|
||||||
|
# Setup mock client
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login = AsyncMock(return_value=True)
|
||||||
|
mock_client.save = MagicMock()
|
||||||
|
|
||||||
|
mock_activities = [
|
||||||
|
{
|
||||||
|
'activityId': '12345',
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'duration': 3600.0,
|
||||||
|
'distance': 25000.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_client.get_activities = MagicMock(return_value=mock_activities)
|
||||||
|
# First two calls fail, third succeeds
|
||||||
|
mock_client.get_activity = MagicMock(side_effect=[
|
||||||
|
Exception("Temporary error"),
|
||||||
|
Exception("Temporary error"),
|
||||||
|
{
|
||||||
|
'activityId': '12345',
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00.000Z',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'duration': 3600.0,
|
||||||
|
'distance': 25000.0,
|
||||||
|
'averageHR': 140.0,
|
||||||
|
'maxHR': 170.0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
# Test sync
|
||||||
|
synced_count = await workout_sync_service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
|
assert synced_count == 1
|
||||||
|
# Verify get_activity was called 3 times (initial + 2 retries)
|
||||||
|
assert mock_client.get_activity.call_count == 3
|
||||||
@@ -17,6 +17,7 @@ async def test_evolve_plan_with_valid_analysis():
|
|||||||
)
|
)
|
||||||
mock_analysis = Analysis(
|
mock_analysis = Analysis(
|
||||||
approved=True,
|
approved=True,
|
||||||
|
suggestions=["More recovery"],
|
||||||
jsonb_feedback={"suggestions": ["More recovery"]}
|
jsonb_feedback={"suggestions": ["More recovery"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ async def test_evolve_plan_with_valid_analysis():
|
|||||||
assert result.version == 2
|
assert result.version == 2
|
||||||
assert result.parent_plan_id == 1
|
assert result.parent_plan_id == 1
|
||||||
mock_db.add.assert_called_once()
|
mock_db.add.assert_called_once()
|
||||||
mock_db.commit.assert_awaited_once()
|
mock_db.commit.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_evolution_skipped_for_unapproved_analysis():
|
async def test_evolution_skipped_for_unapproved_analysis():
|
||||||
@@ -45,12 +46,14 @@ async def test_evolution_skipped_for_unapproved_analysis():
|
|||||||
async def test_evolution_history_retrieval():
|
async def test_evolution_history_retrieval():
|
||||||
"""Test getting plan evolution history"""
|
"""Test getting plan evolution history"""
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_db.execute.return_value.scalars.return_value = [
|
mock_result = AsyncMock()
|
||||||
|
mock_result.scalars.return_value.all.return_value = [
|
||||||
Plan(version=1), Plan(version=2)
|
Plan(version=1), Plan(version=2)
|
||||||
]
|
]
|
||||||
|
mock_db.execute.return_value = mock_result
|
||||||
|
|
||||||
service = PlanEvolutionService(mock_db)
|
service = PlanEvolutionService(mock_db)
|
||||||
history = await service.get_plan_evolution_history(1)
|
history = await service.get_plan_evolution_history(1)
|
||||||
|
history_result = await history
|
||||||
assert len(history) == 2
|
assert len(history_result) == 2
|
||||||
assert history[0].version == 1
|
assert history_result[0].version == 1
|
||||||
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
from backend.app.services.workout_sync import WorkoutSyncService
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
from backend.app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
|
from backend.app.services.garmin import GarminAuthError, GarminAPIError
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -10,59 +11,70 @@ import asyncio
|
|||||||
async def test_successful_sync():
|
async def test_successful_sync():
|
||||||
"""Test successful sync of new activities"""
|
"""Test successful sync of new activities"""
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_garmin = MagicMock()
|
mock_garmin = AsyncMock()
|
||||||
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
|
mock_garmin.get_activities.return_value = [{'activityId': '123', 'startTimeLocal': '2024-01-01T08:00:00', 'duration': 3600, 'distance': 10000, 'activityType': {'typeKey': 'running'}}]
|
||||||
mock_garmin.get_activity_details.return_value = {'metrics': 'data'}
|
mock_garmin.get_activity_details.return_value = {'metrics': 'data'}
|
||||||
|
|
||||||
service = WorkoutSyncService(mock_db)
|
with patch('backend.app.services.workout_sync.WorkoutSyncService.activity_exists', new_callable=AsyncMock) as mock_activity_exists:
|
||||||
service.garmin_service = mock_garmin
|
mock_activity_exists.return_value = False
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
result = await service.sync_recent_activities()
|
service.garmin_service = mock_garmin
|
||||||
|
|
||||||
assert result == 1
|
result = await service.sync_recent_activities()
|
||||||
mock_db.add.assert_called()
|
|
||||||
mock_db.commit.assert_awaited()
|
assert result == 1
|
||||||
|
mock_db.add.assert_called()
|
||||||
|
mock_db.commit.assert_called()
|
||||||
|
mock_activity_exists.assert_awaited_once_with('123')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_duplicate_activity_handling():
|
async def test_duplicate_activity_handling():
|
||||||
"""Test skipping duplicate activities"""
|
"""Test skipping duplicate activities"""
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_db.execute.return_value.scalar_one_or_none.return_value = True
|
with patch('backend.app.services.workout_sync.WorkoutSyncService.activity_exists', new_callable=AsyncMock) as mock_activity_exists:
|
||||||
mock_garmin = MagicMock()
|
mock_activity_exists.return_value = True
|
||||||
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
|
mock_garmin = AsyncMock()
|
||||||
|
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
|
||||||
service = WorkoutSyncService(mock_db)
|
|
||||||
service.garmin_service = mock_garmin
|
service = WorkoutSyncService(mock_db)
|
||||||
|
service.garmin_service = mock_garmin
|
||||||
result = await service.sync_recent_activities()
|
|
||||||
assert result == 0
|
result = await service.sync_recent_activities()
|
||||||
|
assert result == 0
|
||||||
|
mock_activity_exists.assert_awaited_once_with('123')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_activity_detail_retry_logic():
|
async def test_activity_detail_retry_logic():
|
||||||
"""Test retry logic for activity details"""
|
"""Test retry logic for activity details"""
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_garmin = MagicMock()
|
mock_garmin = AsyncMock()
|
||||||
mock_garmin.get_activities.return_value = [{'activityId': '123'}]
|
mock_garmin.get_activities.return_value = [{'activityId': '123', 'startTimeLocal': '2024-01-01T08:00:00', 'duration': 3600, 'distance': 10000, 'activityType': {'typeKey': 'running'}}]
|
||||||
mock_garmin.get_activity_details.side_effect = [Exception(), {'metrics': 'data'}]
|
mock_garmin.get_activity_details.side_effect = [GarminAPIError("Error"), {'metrics': 'data'}]
|
||||||
|
|
||||||
service = WorkoutSyncService(mock_db)
|
with patch('backend.app.services.workout_sync.WorkoutSyncService.activity_exists', new_callable=AsyncMock) as mock_activity_exists:
|
||||||
service.garmin_service = mock_garmin
|
mock_activity_exists.return_value = False
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
result = await service.sync_recent_activities()
|
service.garmin_service = mock_garmin
|
||||||
assert mock_garmin.get_activity_details.call_count == 2
|
|
||||||
assert result == 1
|
result = await service.sync_recent_activities()
|
||||||
|
assert mock_garmin.get_activity_details.call_count == 2
|
||||||
|
assert result == 1
|
||||||
|
mock_activity_exists.assert_awaited_once_with('123')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auth_error_handling():
|
async def test_auth_error_handling():
|
||||||
"""Test authentication error handling"""
|
"""Test authentication error handling"""
|
||||||
mock_db = AsyncMock()
|
mock_db = AsyncMock()
|
||||||
mock_garmin = MagicMock()
|
mock_garmin = AsyncMock()
|
||||||
mock_garmin.get_activities.side_effect = Exception("Auth failed")
|
mock_garmin.get_activities.side_effect = GarminAuthError("Auth failed")
|
||||||
|
|
||||||
service = WorkoutSyncService(mock_db)
|
service = WorkoutSyncService(mock_db)
|
||||||
service.garmin_service = mock_garmin
|
service.garmin_service = mock_garmin
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(GarminAuthError):
|
||||||
await service.sync_recent_activities()
|
await service.sync_recent_activities()
|
||||||
|
|
||||||
sync_log = mock_db.add.call_args[0][0]
|
sync_log = mock_db.add.call_args[0][0]
|
||||||
|
|||||||
266
backend/tests/services/test_workout_sync.py
Normal file
266
backend/tests/services/test_workout_sync.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
|
from backend.app.services.garmin import GarminAPIError, GarminAuthError
|
||||||
|
from backend.app.models.workout import Workout
|
||||||
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_successful_sync():
|
||||||
|
"""Test successful sync of new activities"""
|
||||||
|
# Create proper async mock for database session
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock() # add is synchronous
|
||||||
|
mock_db.commit = AsyncMock()
|
||||||
|
mock_db.refresh = AsyncMock()
|
||||||
|
|
||||||
|
# Mock the activity_exists check to return False (no duplicates)
|
||||||
|
mock_db.execute = AsyncMock()
|
||||||
|
mock_db.execute.return_value.scalar_one_or_none = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
|
||||||
|
# Mock the garmin service methods
|
||||||
|
service.garmin_service.get_activities = AsyncMock(return_value=[
|
||||||
|
{
|
||||||
|
'activityId': '123456',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00Z',
|
||||||
|
'duration': 3600,
|
||||||
|
'distance': 25000
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
service.garmin_service.get_activity_details = AsyncMock(return_value={
|
||||||
|
'averageHR': 150,
|
||||||
|
'maxHR': 180,
|
||||||
|
'avgPower': 250,
|
||||||
|
'elevationGain': 500
|
||||||
|
})
|
||||||
|
|
||||||
|
result = await service.sync_recent_activities(days_back=7)
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
assert mock_db.add.call_count >= 2 # sync_log and workout
|
||||||
|
mock_db.commit.assert_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_duplicate_activity_handling():
|
||||||
|
"""Test skipping duplicate activities"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.commit = AsyncMock()
|
||||||
|
mock_db.refresh = AsyncMock()
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
|
||||||
|
# Mock activity_exists to return True (activity exists)
|
||||||
|
service.activity_exists = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
service.garmin_service.get_activities = AsyncMock(return_value=[
|
||||||
|
{'activityId': '123456', 'startTimeLocal': '2024-01-15T08:00:00Z'}
|
||||||
|
])
|
||||||
|
|
||||||
|
result = await service.sync_recent_activities()
|
||||||
|
|
||||||
|
assert result == 0 # No new activities synced
|
||||||
|
mock_db.commit.assert_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activity_detail_retry_logic():
|
||||||
|
"""Test retry logic for activity details"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.commit = AsyncMock()
|
||||||
|
mock_db.refresh = AsyncMock()
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
service.activity_exists = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
service.garmin_service.get_activities = AsyncMock(return_value=[
|
||||||
|
{
|
||||||
|
'activityId': '123456',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'startTimeLocal': '2024-01-15T08:00:00Z',
|
||||||
|
'duration': 3600
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
# First call fails, second succeeds
|
||||||
|
service.garmin_service.get_activity_details = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
GarminAPIError("Temporary failure"),
|
||||||
|
{'averageHR': 150, 'maxHR': 180}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock asyncio.sleep to avoid actual delays in tests
|
||||||
|
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||||
|
result = await service.sync_recent_activities()
|
||||||
|
|
||||||
|
assert service.garmin_service.get_activity_details.call_count == 2
|
||||||
|
assert result == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_error_handling():
|
||||||
|
"""Test authentication error handling"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.commit = AsyncMock()
|
||||||
|
mock_db.refresh = AsyncMock()
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
|
||||||
|
# Mock authentication failure
|
||||||
|
service.garmin_service.get_activities = AsyncMock(
|
||||||
|
side_effect=GarminAuthError("Authentication failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(GarminAuthError):
|
||||||
|
await service.sync_recent_activities()
|
||||||
|
|
||||||
|
# Check that sync log was created with auth error status
|
||||||
|
sync_log_calls = [call for call in mock_db.add.call_args_list
|
||||||
|
if isinstance(call[0][0], GarminSyncLog)]
|
||||||
|
assert len(sync_log_calls) >= 1
|
||||||
|
sync_log = sync_log_calls[0][0][0]
|
||||||
|
assert sync_log.status == "auth_error"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_error_handling():
|
||||||
|
"""Test API error handling"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.commit = AsyncMock()
|
||||||
|
mock_db.refresh = AsyncMock()
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
|
||||||
|
service.garmin_service.get_activities = AsyncMock(
|
||||||
|
side_effect=GarminAPIError("API rate limit exceeded")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(GarminAPIError):
|
||||||
|
await service.sync_recent_activities()
|
||||||
|
|
||||||
|
# Check sync log status
|
||||||
|
sync_log_calls = [call for call in mock_db.add.call_args_list
|
||||||
|
if isinstance(call[0][0], GarminSyncLog)]
|
||||||
|
sync_log = sync_log_calls[0][0][0]
|
||||||
|
assert sync_log.status == "api_error"
|
||||||
|
assert "rate limit" in sync_log.error_message.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sync_status():
|
||||||
|
"""Test retrieval of latest sync status"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_log = GarminSyncLog(
|
||||||
|
status="success",
|
||||||
|
activities_synced=5,
|
||||||
|
last_sync_time=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the database query
|
||||||
|
mock_result = AsyncMock()
|
||||||
|
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_log)
|
||||||
|
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
result = await service.get_latest_sync_status()
|
||||||
|
|
||||||
|
assert result.status == "success"
|
||||||
|
assert result.activities_synced == 5
|
||||||
|
mock_db.execute.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activity_exists_check():
|
||||||
|
"""Test the activity_exists helper method"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
|
||||||
|
# Mock existing activity
|
||||||
|
mock_workout = Workout(garmin_activity_id="123456")
|
||||||
|
mock_result = AsyncMock()
|
||||||
|
mock_result.scalar_one_or_none = AsyncMock(return_value=mock_workout)
|
||||||
|
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
exists = await service.activity_exists("123456")
|
||||||
|
|
||||||
|
assert exists is True
|
||||||
|
mock_db.execute.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activity_does_not_exist():
|
||||||
|
"""Test activity_exists when activity doesn't exist"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
|
||||||
|
# Mock no existing activity
|
||||||
|
mock_result = AsyncMock()
|
||||||
|
mock_result.scalar_one_or_none = AsyncMock(return_value=None)
|
||||||
|
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
exists = await service.activity_exists("nonexistent")
|
||||||
|
|
||||||
|
assert exists is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_activity_data():
|
||||||
|
"""Test parsing of Garmin activity data"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
|
||||||
|
activity_data = {
|
||||||
|
'activityId': '987654321',
|
||||||
|
'activityType': {'typeKey': 'cycling'},
|
||||||
|
'startTimeLocal': '2024-01-15T08:30:00Z',
|
||||||
|
'duration': 7200,
|
||||||
|
'distance': 50000,
|
||||||
|
'averageHR': 145,
|
||||||
|
'maxHR': 175,
|
||||||
|
'avgPower': 230,
|
||||||
|
'maxPower': 450,
|
||||||
|
'averageBikingCadenceInRevPerMinute': 85,
|
||||||
|
'elevationGain': 800
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service.parse_activity_data(activity_data)
|
||||||
|
|
||||||
|
assert result['garmin_activity_id'] == '987654321'
|
||||||
|
assert result['activity_type'] == 'cycling'
|
||||||
|
assert result['duration_seconds'] == 7200
|
||||||
|
assert result['distance_m'] == 50000
|
||||||
|
assert result['avg_hr'] == 145
|
||||||
|
assert result['max_hr'] == 175
|
||||||
|
assert result['avg_power'] == 230
|
||||||
|
assert result['max_power'] == 450
|
||||||
|
assert result['avg_cadence'] == 85
|
||||||
|
assert result['elevation_gain_m'] == 800
|
||||||
|
assert result['metrics'] == activity_data # Full data stored as JSONB
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_with_network_timeout():
|
||||||
|
"""Test handling of network timeouts during sync"""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.add = MagicMock()
|
||||||
|
mock_db.commit = AsyncMock()
|
||||||
|
mock_db.refresh = AsyncMock()
|
||||||
|
|
||||||
|
service = WorkoutSyncService(mock_db)
|
||||||
|
|
||||||
|
# Simulate timeout error
|
||||||
|
import asyncio
|
||||||
|
service.garmin_service.get_activities = AsyncMock(
|
||||||
|
side_effect=asyncio.TimeoutError("Request timed out")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # Should raise the timeout error
|
||||||
|
await service.sync_recent_activities()
|
||||||
|
|
||||||
|
# Verify error was logged
|
||||||
|
sync_log_calls = [call for call in mock_db.add.call_args_list
|
||||||
|
if isinstance(call[0][0], GarminSyncLog)]
|
||||||
|
sync_log = sync_log_calls[0][0][0]
|
||||||
|
assert sync_log.status == "error"
|
||||||
58
install.sh
58
install.sh
@@ -1,58 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# AI Cycling Coach Installation Script
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚴 AI Cycling Coach Installation"
|
|
||||||
echo "================================="
|
|
||||||
|
|
||||||
# Check Python version
|
|
||||||
python_version=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
|
||||||
required_version="3.8"
|
|
||||||
|
|
||||||
if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then
|
|
||||||
echo "❌ Error: Python 3.8 or higher is required. Found: $python_version"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Python version $python_version is compatible"
|
|
||||||
|
|
||||||
# Create virtual environment if it doesn't exist
|
|
||||||
if [ ! -d "venv" ]; then
|
|
||||||
echo "📦 Creating virtual environment..."
|
|
||||||
python3 -m venv venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Activate virtual environment
|
|
||||||
echo "🔧 Activating virtual environment..."
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Upgrade pip
|
|
||||||
echo "⬆️ Upgrading pip..."
|
|
||||||
pip install --upgrade pip
|
|
||||||
|
|
||||||
# Install the application
|
|
||||||
echo "📋 Installing AI Cycling Coach..."
|
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
# Initialize database
|
|
||||||
echo "🗄️ Initializing database..."
|
|
||||||
make init-db
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🎉 Installation complete!"
|
|
||||||
echo ""
|
|
||||||
echo "To run the application:"
|
|
||||||
echo " 1. Activate the virtual environment: source venv/bin/activate"
|
|
||||||
echo " 2. Run the app: cycling-coach"
|
|
||||||
echo ""
|
|
||||||
echo "Or use the Makefile:"
|
|
||||||
echo " make run"
|
|
||||||
echo ""
|
|
||||||
echo "Configure your settings in .env file:"
|
|
||||||
echo " - OPENROUTER_API_KEY: Your AI API key"
|
|
||||||
echo " - GARMIN_USERNAME: Your Garmin Connect username"
|
|
||||||
echo " - GARMIN_PASSWORD: Your Garmin Connect password"
|
|
||||||
echo ""
|
|
||||||
echo "Happy training! 🚴♂️"
|
|
||||||
0
logs/backend.log
Normal file
0
logs/backend.log
Normal file
86
main.py
86
main.py
@@ -3,16 +3,19 @@
|
|||||||
AI Cycling Coach - CLI TUI Application
|
AI Cycling Coach - CLI TUI Application
|
||||||
Entry point for the terminal-based cycling training coach.
|
Entry point for the terminal-based cycling training coach.
|
||||||
"""
|
"""
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
Header, Footer, Static, Button, DataTable,
|
Header, Footer, Static, Button, DataTable,
|
||||||
Placeholder, TabbedContent, TabPane
|
Placeholder, TabbedContent, TabPane
|
||||||
)
|
)
|
||||||
from textual.logging import TextualHandler
|
from textual.logging import TextualHandler
|
||||||
@@ -26,6 +29,8 @@ from tui.views.workouts import WorkoutView
|
|||||||
from tui.views.plans import PlanView
|
from tui.views.plans import PlanView
|
||||||
from tui.views.rules import RuleView
|
from tui.views.rules import RuleView
|
||||||
from tui.views.routes import RouteView
|
from tui.views.routes import RouteView
|
||||||
|
from backend.app.database import AsyncSessionLocal
|
||||||
|
from tui.services.workout_service import WorkoutService
|
||||||
|
|
||||||
|
|
||||||
class CyclingCoachApp(App):
|
class CyclingCoachApp(App):
|
||||||
@@ -93,9 +98,12 @@ class CyclingCoachApp(App):
|
|||||||
logger.addHandler(textual_handler)
|
logger.addHandler(textual_handler)
|
||||||
|
|
||||||
# Add file handler
|
# Add file handler
|
||||||
file_handler = logging.FileHandler(logs_dir / "app.log")
|
# Add file handler for rotating logs
|
||||||
|
file_handler = logging.handlers.RotatingFileHandler(
|
||||||
|
logs_dir / "app.log", maxBytes=1024 * 1024 * 5, backupCount=5 # 5MB
|
||||||
|
)
|
||||||
file_handler.setFormatter(
|
file_handler.setFormatter(
|
||||||
logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
)
|
)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
@@ -190,8 +198,80 @@ async def init_db_async():
|
|||||||
sys.stdout.write(f"Database initialization failed: {e}\n")
|
sys.stdout.write(f"Database initialization failed: {e}\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
async def list_workouts_cli():
|
||||||
|
"""Display workouts in CLI format without starting TUI."""
|
||||||
|
try:
|
||||||
|
# Initialize database
|
||||||
|
await init_db_async()
|
||||||
|
|
||||||
|
# Get workouts using WorkoutService
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
workout_service = WorkoutService(db)
|
||||||
|
workouts = await workout_service.get_workouts(limit=50)
|
||||||
|
|
||||||
|
if not workouts:
|
||||||
|
print("No workouts found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Print header
|
||||||
|
print("AI Cycling Coach - Workouts")
|
||||||
|
print("=" * 80)
|
||||||
|
print(f"{'Date':<12} {'Type':<15} {'Duration':<10} {'Distance':<10} {'Avg HR':<8} {'Avg Power':<10}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
# Print each workout
|
||||||
|
for workout in workouts:
|
||||||
|
# Format date
|
||||||
|
date_str = "Unknown"
|
||||||
|
if workout.get("start_time"):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
|
||||||
|
date_str = dt.strftime("%m/%d %H:%M")
|
||||||
|
except:
|
||||||
|
date_str = workout["start_time"][:10]
|
||||||
|
|
||||||
|
# Format duration
|
||||||
|
duration_str = "N/A"
|
||||||
|
if workout.get("duration_seconds"):
|
||||||
|
minutes = workout["duration_seconds"] // 60
|
||||||
|
duration_str = f"{minutes}min"
|
||||||
|
|
||||||
|
# Format distance
|
||||||
|
distance_str = "N/A"
|
||||||
|
if workout.get("distance_m"):
|
||||||
|
distance_str = f"{workout['distance_m'] / 1000:.1f}km"
|
||||||
|
|
||||||
|
# Format heart rate
|
||||||
|
hr_str = "N/A"
|
||||||
|
if workout.get("avg_hr"):
|
||||||
|
hr_str = f"{workout['avg_hr']} BPM"
|
||||||
|
|
||||||
|
# Format power
|
||||||
|
power_str = "N/A"
|
||||||
|
if workout.get("avg_power"):
|
||||||
|
power_str = f"{workout['avg_power']} W"
|
||||||
|
|
||||||
|
print(f"{date_str:<12} {workout.get('activity_type', 'Unknown')[:14]:<15} {duration_str:<10} {distance_str:<10} {hr_str:<8} {power_str:<10}")
|
||||||
|
|
||||||
|
print(f"\nTotal workouts: {len(workouts)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing workouts: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point for the CLI application."""
|
"""Main entry point for the CLI application."""
|
||||||
|
parser = argparse.ArgumentParser(description="AI Cycling Coach - Terminal Training Interface")
|
||||||
|
parser.add_argument("--list-workouts", action="store_true",
|
||||||
|
help="List all workouts in CLI format and exit")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle CLI commands that don't need TUI
|
||||||
|
if args.list_workouts:
|
||||||
|
asyncio.run(list_workouts_cli())
|
||||||
|
return
|
||||||
|
|
||||||
# Create data directory if it doesn't exist
|
# Create data directory if it doesn't exist
|
||||||
data_dir = Path("data")
|
data_dir = Path("data")
|
||||||
data_dir.mkdir(exist_ok=True)
|
data_dir.mkdir(exist_ok=True)
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ requires-python = ">=3.8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
# Core dependencies
|
# Core dependencies
|
||||||
"python-dotenv==1.0.1",
|
"python-dotenv==1.0.1",
|
||||||
"sqlalchemy==2.0.29",
|
"sqlalchemy==2.0.31",
|
||||||
"alembic==1.13.1",
|
"alembic==1.13.1",
|
||||||
"pydantic-settings==2.2.1",
|
"pydantic-settings==2.2.1",
|
||||||
|
"Mako==1.3.10",
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
"aiosqlite==0.20.0",
|
"aiosqlite==0.20.0",
|
||||||
@@ -48,6 +49,10 @@ dependencies = [
|
|||||||
"garth==0.4.46",
|
"garth==0.4.46",
|
||||||
"httpx==0.25.2",
|
"httpx==0.25.2",
|
||||||
|
|
||||||
|
# Backend framework
|
||||||
|
"fastapi==0.110.0",
|
||||||
|
"python-multipart==0.0.9",
|
||||||
|
|
||||||
# Development tools (optional)
|
# Development tools (optional)
|
||||||
"pytest>=8.1.1; extra=='dev'",
|
"pytest>=8.1.1; extra=='dev'",
|
||||||
"pytest-asyncio>=0.23.5; extra=='dev'",
|
"pytest-asyncio>=0.23.5; extra=='dev'",
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
sqlalchemy>=2.0.35
|
sqlalchemy==2.0.31
|
||||||
alembic>=1.13.1
|
alembic==1.13.1
|
||||||
pydantic-settings==2.2.1
|
pydantic-settings==2.2.1
|
||||||
|
Mako==1.3.10
|
||||||
|
|
||||||
# TUI framework
|
# TUI framework
|
||||||
textual
|
textual
|
||||||
|
|
||||||
|
# Backend framework
|
||||||
|
fastapi==0.110.0
|
||||||
|
python-multipart==0.0.9
|
||||||
|
|
||||||
# Data processing
|
# Data processing
|
||||||
gpxpy # GPX parsing library
|
gpxpy # GPX parsing library
|
||||||
|
|
||||||
@@ -23,4 +28,5 @@ pytest-asyncio==0.23.5
|
|||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
black==24.3.0
|
black==24.3.0
|
||||||
isort==5.13.2
|
isort==5.13.2
|
||||||
|
greenlet>=1.1.0
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Workout service for TUI application.
|
Enhanced workout service with debugging for TUI application.
|
||||||
Manages workout data, analysis, and Garmin sync without HTTP dependencies.
|
|
||||||
"""
|
"""
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
@@ -20,17 +19,39 @@ class WorkoutService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get_workouts(self, limit: Optional[int] = None) -> List[Dict]:
|
async def get_workouts(self, limit: Optional[int] = None) -> List[Dict]:
|
||||||
"""Get all workouts."""
|
"""Get all workouts with enhanced debugging."""
|
||||||
try:
|
try:
|
||||||
|
print(f"WorkoutService.get_workouts: Starting query with limit={limit}")
|
||||||
|
|
||||||
|
# First, let's check if the table exists and has data
|
||||||
|
count_result = await self.db.execute(text("SELECT COUNT(*) FROM workouts"))
|
||||||
|
total_count = count_result.scalar()
|
||||||
|
print(f"WorkoutService.get_workouts: Total workouts in database: {total_count}")
|
||||||
|
|
||||||
|
if total_count == 0:
|
||||||
|
print("WorkoutService.get_workouts: No workouts found in database")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build the query
|
||||||
query = select(Workout).order_by(desc(Workout.start_time))
|
query = select(Workout).order_by(desc(Workout.start_time))
|
||||||
if limit:
|
if limit:
|
||||||
query = query.limit(limit)
|
query = query.limit(limit)
|
||||||
|
|
||||||
result = await self.db.execute(query)
|
print(f"WorkoutService.get_workouts: Executing query: {query}")
|
||||||
workouts = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
# Execute the query
|
||||||
{
|
result = await self.db.execute(query)
|
||||||
|
print("WorkoutService.get_workouts: Query executed successfully")
|
||||||
|
|
||||||
|
# Get all workouts
|
||||||
|
workouts = result.scalars().all()
|
||||||
|
print(f"WorkoutService.get_workouts: Retrieved {len(workouts)} workout objects")
|
||||||
|
|
||||||
|
# Convert to dictionaries
|
||||||
|
workout_dicts = []
|
||||||
|
for i, w in enumerate(workouts):
|
||||||
|
print(f"WorkoutService.get_workouts: Processing workout {i+1}: ID={w.id}, Type={w.activity_type}")
|
||||||
|
workout_dict = {
|
||||||
"id": w.id,
|
"id": w.id,
|
||||||
"garmin_activity_id": w.garmin_activity_id,
|
"garmin_activity_id": w.garmin_activity_id,
|
||||||
"activity_type": w.activity_type,
|
"activity_type": w.activity_type,
|
||||||
@@ -43,15 +64,65 @@ class WorkoutService:
|
|||||||
"max_power": w.max_power,
|
"max_power": w.max_power,
|
||||||
"avg_cadence": w.avg_cadence,
|
"avg_cadence": w.avg_cadence,
|
||||||
"elevation_gain_m": w.elevation_gain_m
|
"elevation_gain_m": w.elevation_gain_m
|
||||||
} for w in workouts
|
}
|
||||||
]
|
workout_dicts.append(workout_dict)
|
||||||
|
|
||||||
|
print(f"WorkoutService.get_workouts: Returning {len(workout_dicts)} workouts")
|
||||||
|
return workout_dicts
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Enhanced error logging
|
||||||
|
import traceback
|
||||||
|
print(f"WorkoutService.get_workouts: ERROR: {str(e)}")
|
||||||
|
print(f"WorkoutService.get_workouts: Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
# Log error properly
|
# Log error properly
|
||||||
import logging
|
import logging
|
||||||
logging.error(f"Error fetching workouts: {str(e)}")
|
logging.error(f"Error fetching workouts: {str(e)}")
|
||||||
|
logging.error(f"Traceback: {traceback.format_exc()}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def debug_database_connection(self) -> Dict:
|
||||||
|
"""Debug method to check database connection and table status."""
|
||||||
|
debug_info = {}
|
||||||
|
try:
|
||||||
|
# Check database connection
|
||||||
|
result = await self.db.execute(text("SELECT 1"))
|
||||||
|
debug_info["connection"] = "OK"
|
||||||
|
|
||||||
|
# Check if workouts table exists
|
||||||
|
table_check = await self.db.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='table' AND name='workouts'")
|
||||||
|
)
|
||||||
|
table_exists = table_check.fetchone()
|
||||||
|
debug_info["workouts_table_exists"] = bool(table_exists)
|
||||||
|
|
||||||
|
if table_exists:
|
||||||
|
# Get table schema
|
||||||
|
schema_result = await self.db.execute(text("PRAGMA table_info(workouts)"))
|
||||||
|
schema = schema_result.fetchall()
|
||||||
|
debug_info["workouts_schema"] = [dict(row._mapping) for row in schema]
|
||||||
|
|
||||||
|
# Get row count
|
||||||
|
count_result = await self.db.execute(text("SELECT COUNT(*) FROM workouts"))
|
||||||
|
debug_info["workouts_count"] = count_result.scalar()
|
||||||
|
|
||||||
|
# Get sample data if any
|
||||||
|
if debug_info["workouts_count"] > 0:
|
||||||
|
sample_result = await self.db.execute(text("SELECT * FROM workouts LIMIT 3"))
|
||||||
|
sample_data = sample_result.fetchall()
|
||||||
|
debug_info["sample_workouts"] = [dict(row._mapping) for row in sample_data]
|
||||||
|
|
||||||
|
return debug_info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
debug_info["error"] = str(e)
|
||||||
|
debug_info["traceback"] = traceback.format_exc()
|
||||||
|
return debug_info
|
||||||
|
|
||||||
|
# ... rest of the methods remain the same ...
|
||||||
|
|
||||||
async def get_workout(self, workout_id: int) -> Optional[Dict]:
|
async def get_workout(self, workout_id: int) -> Optional[Dict]:
|
||||||
"""Get a specific workout by ID."""
|
"""Get a specific workout by ID."""
|
||||||
try:
|
try:
|
||||||
@@ -76,130 +147,4 @@ class WorkoutService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error fetching workout {workout_id}: {str(e)}")
|
raise Exception(f"Error fetching workout {workout_id}: {str(e)}")
|
||||||
|
|
||||||
async def get_workout_metrics(self, workout_id: int) -> List[Dict]:
|
|
||||||
"""Get time-series metrics for a workout."""
|
|
||||||
try:
|
|
||||||
workout = await self.db.get(Workout, workout_id)
|
|
||||||
if not workout or not workout.metrics:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return workout.metrics
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error fetching workout metrics: {str(e)}")
|
|
||||||
|
|
||||||
async def sync_garmin_activities(self, days_back: int = 14) -> Dict:
|
|
||||||
"""Trigger Garmin sync in background."""
|
|
||||||
try:
|
|
||||||
sync_service = WorkoutSyncService(self.db)
|
|
||||||
result = await sync_service.sync_recent_activities(days_back=days_back)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Garmin sync completed",
|
|
||||||
"activities_synced": result.get("activities_synced", 0),
|
|
||||||
"status": "success"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"message": f"Garmin sync failed: {str(e)}",
|
|
||||||
"activities_synced": 0,
|
|
||||||
"status": "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_sync_status(self) -> Dict:
|
|
||||||
"""Get the latest sync status."""
|
|
||||||
try:
|
|
||||||
result = await self.db.execute(
|
|
||||||
select(GarminSyncLog).order_by(desc(GarminSyncLog.created_at)).limit(1)
|
|
||||||
)
|
|
||||||
sync_log = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not sync_log:
|
|
||||||
return {"status": "never_synced"}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": sync_log.status,
|
|
||||||
"last_sync_time": sync_log.last_sync_time.isoformat() if sync_log.last_sync_time else None,
|
|
||||||
"activities_synced": sync_log.activities_synced,
|
|
||||||
"error_message": sync_log.error_message
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error fetching sync status: {str(e)}")
|
|
||||||
|
|
||||||
async def analyze_workout(self, workout_id: int) -> Dict:
|
|
||||||
"""Trigger AI analysis of a specific workout."""
|
|
||||||
try:
|
|
||||||
workout = await self.db.get(Workout, workout_id)
|
|
||||||
if not workout:
|
|
||||||
raise Exception("Workout not found")
|
|
||||||
|
|
||||||
ai_service = AIService(self.db)
|
|
||||||
analysis_result = await ai_service.analyze_workout(workout, None)
|
|
||||||
|
|
||||||
# Store analysis
|
|
||||||
analysis = Analysis(
|
|
||||||
workout_id=workout.id,
|
|
||||||
jsonb_feedback=analysis_result.get("feedback", {}),
|
|
||||||
suggestions=analysis_result.get("suggestions", {})
|
|
||||||
)
|
|
||||||
self.db.add(analysis)
|
|
||||||
await self.db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Analysis completed",
|
|
||||||
"workout_id": workout_id,
|
|
||||||
"analysis_id": analysis.id,
|
|
||||||
"feedback": analysis_result.get("feedback", {}),
|
|
||||||
"suggestions": analysis_result.get("suggestions", {})
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error analyzing workout: {str(e)}")
|
|
||||||
|
|
||||||
async def get_workout_analyses(self, workout_id: int) -> List[Dict]:
|
|
||||||
"""Get all analyses for a specific workout."""
|
|
||||||
try:
|
|
||||||
workout = await self.db.get(Workout, workout_id)
|
|
||||||
if not workout:
|
|
||||||
raise Exception("Workout not found")
|
|
||||||
|
|
||||||
result = await self.db.execute(
|
|
||||||
select(Analysis).where(Analysis.workout_id == workout_id)
|
|
||||||
)
|
|
||||||
analyses = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": a.id,
|
|
||||||
"analysis_type": a.analysis_type,
|
|
||||||
"feedback": a.jsonb_feedback,
|
|
||||||
"suggestions": a.suggestions,
|
|
||||||
"approved": a.approved,
|
|
||||||
"created_at": a.created_at.isoformat() if a.created_at else None
|
|
||||||
} for a in analyses
|
|
||||||
]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error fetching workout analyses: {str(e)}")
|
|
||||||
|
|
||||||
async def approve_analysis(self, analysis_id: int) -> Dict:
|
|
||||||
"""Approve analysis suggestions."""
|
|
||||||
try:
|
|
||||||
analysis = await self.db.get(Analysis, analysis_id)
|
|
||||||
if not analysis:
|
|
||||||
raise Exception("Analysis not found")
|
|
||||||
|
|
||||||
analysis.approved = True
|
|
||||||
await self.db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Analysis approved",
|
|
||||||
"analysis_id": analysis_id
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error approving analysis: {str(e)}")
|
|
||||||
@@ -156,6 +156,7 @@ class WorkoutView(BaseView):
|
|||||||
workout_analyses = reactive([])
|
workout_analyses = reactive([])
|
||||||
loading = reactive(True)
|
loading = reactive(True)
|
||||||
sync_status = reactive({})
|
sync_status = reactive({})
|
||||||
|
error_message = reactive(None)
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
.view-title {
|
.view-title {
|
||||||
@@ -191,6 +192,15 @@ class WorkoutView(BaseView):
|
|||||||
padding: 1;
|
padding: 1;
|
||||||
margin: 1 0;
|
margin: 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: $error;
|
||||||
|
text-style: bold;
|
||||||
|
background: #2a0a0a; /* Dark red background */
|
||||||
|
padding: 1;
|
||||||
|
margin: 1 0;
|
||||||
|
border: round $error;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class WorkoutSelected(Message):
|
class WorkoutSelected(Message):
|
||||||
@@ -210,7 +220,14 @@ class WorkoutView(BaseView):
|
|||||||
sys.stdout.write("WorkoutView.compose: START\n")
|
sys.stdout.write("WorkoutView.compose: START\n")
|
||||||
yield Static("Workout Management", classes="view-title")
|
yield Static("Workout Management", classes="view-title")
|
||||||
|
|
||||||
if self.loading:
|
if self.error_message:
|
||||||
|
yield Static(
|
||||||
|
f"Error: {self.error_message}",
|
||||||
|
classes="error-message",
|
||||||
|
id="error-display"
|
||||||
|
)
|
||||||
|
yield Button("Retry Loading", id="retry-loading-btn", variant="primary")
|
||||||
|
elif self.loading:
|
||||||
yield LoadingSpinner("Loading workouts...")
|
yield LoadingSpinner("Loading workouts...")
|
||||||
else:
|
else:
|
||||||
with TabbedContent():
|
with TabbedContent():
|
||||||
@@ -231,12 +248,31 @@ class WorkoutView(BaseView):
|
|||||||
self.load_data()
|
self.load_data()
|
||||||
sys.stdout.write("WorkoutView.on_mount: END\n")
|
sys.stdout.write("WorkoutView.on_mount: END\n")
|
||||||
|
|
||||||
|
async def _load_workouts_with_timeout(self) -> tuple[list, dict]:
|
||||||
|
"""Load workouts with 5-second timeout."""
|
||||||
|
try:
|
||||||
|
# Wrap the actual loading with timeout
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
self._load_workouts_data(),
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise Exception("Loading timed out after 5 seconds")
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
def load_data(self) -> None:
|
def load_data(self) -> None:
|
||||||
"""Public method to trigger data loading for the workout view."""
|
"""Public method to trigger data loading for the workout view."""
|
||||||
sys.stdout.write("WorkoutView.load_data: START\n")
|
sys.stdout.write("WorkoutView.load_data: START\n")
|
||||||
self.loading = True
|
self.loading = True
|
||||||
self.run_async(self._load_workouts_data(), self.on_workouts_loaded)
|
self.run_async(
|
||||||
sys.stdout.write("WorkoutView.load_data: END\n")
|
self._async_wrapper(
|
||||||
|
self._load_workouts_with_timeout(),
|
||||||
|
self.on_workouts_loaded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sys.stdout.write("WorkoutView.load_data: END\n")
|
||||||
|
|
||||||
async def _load_workouts_data(self) -> tuple[list, dict]:
|
async def _load_workouts_data(self) -> tuple[list, dict]:
|
||||||
"""Load workouts and sync status (async worker)."""
|
"""Load workouts and sync status (async worker)."""
|
||||||
@@ -272,6 +308,7 @@ class WorkoutView(BaseView):
|
|||||||
self.workouts = workouts
|
self.workouts = workouts
|
||||||
self.sync_status = sync_status
|
self.sync_status = sync_status
|
||||||
self.loading = False
|
self.loading = False
|
||||||
|
self.error_message = None
|
||||||
self.refresh(layout=True)
|
self.refresh(layout=True)
|
||||||
self.populate_workouts_table()
|
self.populate_workouts_table()
|
||||||
self.update_sync_status()
|
self.update_sync_status()
|
||||||
@@ -280,9 +317,11 @@ class WorkoutView(BaseView):
|
|||||||
sys.stdout.write(f"WorkoutView.on_workouts_loaded: ERROR: {e}\n")
|
sys.stdout.write(f"WorkoutView.on_workouts_loaded: ERROR: {e}\n")
|
||||||
self.log(f"Error in on_workouts_loaded: {e}", severity="error")
|
self.log(f"Error in on_workouts_loaded: {e}", severity="error")
|
||||||
self.loading = False
|
self.loading = False
|
||||||
|
self.error_message = f"Failed to process loaded data: {str(e)}"
|
||||||
self.refresh()
|
self.refresh()
|
||||||
finally:
|
finally:
|
||||||
sys.stdout.write("WorkoutView.on_workouts_loaded: FINALLY\n")
|
sys.stdout.write("WorkoutView.on_workouts_loaded: FINALLY\n")
|
||||||
|
|
||||||
|
|
||||||
async def populate_workouts_table(self) -> None:
|
async def populate_workouts_table(self) -> None:
|
||||||
"""Populate the workouts table."""
|
"""Populate the workouts table."""
|
||||||
@@ -361,6 +400,9 @@ class WorkoutView(BaseView):
|
|||||||
await self.check_sync_status()
|
await self.check_sync_status()
|
||||||
elif event.button.id == "analyze-workout-btn":
|
elif event.button.id == "analyze-workout-btn":
|
||||||
await self.analyze_selected_workout()
|
await self.analyze_selected_workout()
|
||||||
|
elif event.button.id == "retry-loading-btn":
|
||||||
|
self.error_message = None
|
||||||
|
await self.load_data()
|
||||||
elif event.button.id.startswith("approve-analysis-"):
|
elif event.button.id.startswith("approve-analysis-"):
|
||||||
analysis_id = int(event.button.id.split("-")[-1])
|
analysis_id = int(event.button.id.split("-")[-1])
|
||||||
await self.approve_analysis(analysis_id)
|
await self.approve_analysis(analysis_id)
|
||||||
@@ -487,5 +529,10 @@ class WorkoutView(BaseView):
|
|||||||
|
|
||||||
def watch_loading(self, loading: bool) -> None:
|
def watch_loading(self, loading: bool) -> None:
|
||||||
"""React to loading state changes."""
|
"""React to loading state changes."""
|
||||||
|
if hasattr(self, '_mounted') and self._mounted:
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def watch_error_message(self, error_message: Optional[str]) -> None:
|
||||||
|
"""React to error message changes."""
|
||||||
if hasattr(self, '_mounted') and self._mounted:
|
if hasattr(self, '_mounted') and self._mounted:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
Reference in New Issue
Block a user