mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-01-25 16:41:58 +00:00
change to TUI
This commit is contained in:
18
tui/services/__init__.py
Normal file
18
tui/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Service layer for TUI application.
|
||||
Contains business logic extracted from FastAPI routes for direct method calls.
|
||||
"""
|
||||
|
||||
from .dashboard_service import DashboardService
|
||||
from .plan_service import PlanService
|
||||
from .workout_service import WorkoutService
|
||||
from .rule_service import RuleService
|
||||
from .route_service import RouteService
|
||||
|
||||
__all__ = [
|
||||
'DashboardService',
|
||||
'PlanService',
|
||||
'WorkoutService',
|
||||
'RuleService',
|
||||
'RouteService'
|
||||
]
|
||||
BIN
tui/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/dashboard_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/dashboard_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/plan_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/plan_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/route_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/route_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/rule_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/rule_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/workout_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/workout_service.cpython-313.pyc
Normal file
Binary file not shown.
110
tui/services/dashboard_service.py
Normal file
110
tui/services/dashboard_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
"""
|
||||
Dashboard service for TUI application.
|
||||
Provides dashboard data without HTTP dependencies.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Service for dashboard data operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_dashboard_data(self) -> Dict:
|
||||
"""Get consolidated dashboard data."""
|
||||
try:
|
||||
# Recent workouts (last 7 days)
|
||||
workout_result = await self.db.execute(
|
||||
select(Workout)
|
||||
.where(Workout.start_time >= datetime.now() - timedelta(days=7))
|
||||
.order_by(desc(Workout.start_time))
|
||||
.limit(5)
|
||||
)
|
||||
recent_workouts = workout_result.scalars().all()
|
||||
|
||||
# Current active plan - Modified since Plan model doesn't have 'active' field
|
||||
plan_result = await self.db.execute(
|
||||
select(Plan)
|
||||
.order_by(desc(Plan.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
current_plan = plan_result.scalar_one_or_none()
|
||||
|
||||
# Sync status
|
||||
sync_result = await self.db.execute(
|
||||
select(GarminSyncLog)
|
||||
.order_by(desc(GarminSyncLog.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
last_sync = sync_result.scalar_one_or_none()
|
||||
|
||||
# Calculate metrics
|
||||
total_duration = sum(w.duration_seconds or 0 for w in recent_workouts)
|
||||
weekly_volume = total_duration / 3600 if total_duration else 0
|
||||
|
||||
return {
|
||||
"recent_workouts": [
|
||||
{
|
||||
"id": w.id,
|
||||
"activity_type": w.activity_type,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"duration_seconds": w.duration_seconds,
|
||||
"distance_m": w.distance_m,
|
||||
"avg_hr": w.avg_hr,
|
||||
"avg_power": w.avg_power
|
||||
} for w in recent_workouts
|
||||
],
|
||||
"current_plan": {
|
||||
"id": current_plan.id,
|
||||
"version": current_plan.version,
|
||||
"created_at": current_plan.created_at.isoformat() if current_plan.created_at else None
|
||||
} if current_plan else None,
|
||||
"last_sync": {
|
||||
"last_sync_time": last_sync.last_sync_time.isoformat() if last_sync and last_sync.last_sync_time else None,
|
||||
"activities_synced": last_sync.activities_synced if last_sync else 0,
|
||||
"status": last_sync.status if last_sync else "never_synced",
|
||||
"error_message": last_sync.error_message if last_sync else None
|
||||
} if last_sync else None,
|
||||
"metrics": {
|
||||
"weekly_volume": weekly_volume,
|
||||
"workout_count": len(recent_workouts),
|
||||
"plan_progress": 0 # TODO: Calculate actual progress
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Dashboard data error: {str(e)}")
|
||||
|
||||
async def get_weekly_stats(self) -> Dict:
|
||||
"""Get weekly workout statistics."""
|
||||
try:
|
||||
week_start = datetime.now() - timedelta(days=7)
|
||||
|
||||
workout_result = await self.db.execute(
|
||||
select(Workout)
|
||||
.where(Workout.start_time >= week_start)
|
||||
)
|
||||
workouts = workout_result.scalars().all()
|
||||
|
||||
total_distance = sum(w.distance_m or 0 for w in workouts) / 1000 # Convert to km
|
||||
total_time = sum(w.duration_seconds or 0 for w in workouts) / 3600 # Convert to hours
|
||||
|
||||
return {
|
||||
"workout_count": len(workouts),
|
||||
"total_distance_km": round(total_distance, 1),
|
||||
"total_time_hours": round(total_time, 1),
|
||||
"avg_distance_km": round(total_distance / len(workouts), 1) if workouts else 0,
|
||||
"avg_duration_hours": round(total_time / len(workouts), 1) if workouts else 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Weekly stats error: {str(e)}")
|
||||
199
tui/services/plan_service.py
Normal file
199
tui/services/plan_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Plan service for TUI application.
|
||||
Manages training plans and plan generation without HTTP dependencies.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.models.rule import Rule
|
||||
from backend.app.services.ai_service import AIService
|
||||
from backend.app.services.plan_evolution import PlanEvolutionService
|
||||
|
||||
|
||||
class PlanService:
|
||||
"""Service for training plan operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_plans(self) -> List[Dict]:
|
||||
"""Get all training plans."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Plan).order_by(desc(Plan.created_at))
|
||||
)
|
||||
plans = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"version": p.version,
|
||||
"parent_plan_id": p.parent_plan_id,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"plan_data": p.jsonb_plan
|
||||
} for p in plans
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching plans: {str(e)}")
|
||||
|
||||
async def get_plan(self, plan_id: int) -> Optional[Dict]:
|
||||
"""Get a specific plan by ID."""
|
||||
try:
|
||||
plan = await self.db.get(Plan, plan_id)
|
||||
if not plan:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": plan.id,
|
||||
"version": plan.version,
|
||||
"parent_plan_id": plan.parent_plan_id,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
||||
"plan_data": plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching plan {plan_id}: {str(e)}")
|
||||
|
||||
async def create_plan(self, plan_data: Dict, version: int = 1, parent_plan_id: Optional[int] = None) -> Dict:
|
||||
"""Create a new training plan."""
|
||||
try:
|
||||
db_plan = Plan(
|
||||
jsonb_plan=plan_data,
|
||||
version=version,
|
||||
parent_plan_id=parent_plan_id
|
||||
)
|
||||
self.db.add(db_plan)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_plan)
|
||||
|
||||
return {
|
||||
"id": db_plan.id,
|
||||
"version": db_plan.version,
|
||||
"parent_plan_id": db_plan.parent_plan_id,
|
||||
"created_at": db_plan.created_at.isoformat() if db_plan.created_at else None,
|
||||
"plan_data": db_plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating plan: {str(e)}")
|
||||
|
||||
async def update_plan(self, plan_id: int, plan_data: Dict, version: Optional[int] = None) -> Dict:
|
||||
"""Update an existing plan."""
|
||||
try:
|
||||
db_plan = await self.db.get(Plan, plan_id)
|
||||
if not db_plan:
|
||||
raise Exception("Plan not found")
|
||||
|
||||
db_plan.jsonb_plan = plan_data
|
||||
if version is not None:
|
||||
db_plan.version = version
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_plan)
|
||||
|
||||
return {
|
||||
"id": db_plan.id,
|
||||
"version": db_plan.version,
|
||||
"parent_plan_id": db_plan.parent_plan_id,
|
||||
"created_at": db_plan.created_at.isoformat() if db_plan.created_at else None,
|
||||
"plan_data": db_plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error updating plan: {str(e)}")
|
||||
|
||||
async def delete_plan(self, plan_id: int) -> Dict:
|
||||
"""Delete a plan."""
|
||||
try:
|
||||
plan = await self.db.get(Plan, plan_id)
|
||||
if not plan:
|
||||
raise Exception("Plan not found")
|
||||
|
||||
await self.db.delete(plan)
|
||||
await self.db.commit()
|
||||
|
||||
return {"message": "Plan deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error deleting plan: {str(e)}")
|
||||
|
||||
async def generate_plan(self, rule_ids: List[int], goals: Dict, preferred_routes: Optional[List[str]] = None) -> Dict:
|
||||
"""Generate a new training plan using AI."""
|
||||
try:
|
||||
# Get all rules from the provided rule IDs
|
||||
rules = []
|
||||
for rule_id in rule_ids:
|
||||
rule = await self.db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
raise Exception(f"Rule with ID {rule_id} not found")
|
||||
rules.append(rule.rule_text)
|
||||
|
||||
# Generate plan using AI service
|
||||
ai_service = AIService(self.db)
|
||||
generated_plan = await ai_service.generate_training_plan(
|
||||
rule_set=rules,
|
||||
goals=goals,
|
||||
preferred_routes=preferred_routes or []
|
||||
)
|
||||
|
||||
# Create and store the plan
|
||||
plan_dict = await self.create_plan(generated_plan, version=1)
|
||||
|
||||
return {
|
||||
"plan": plan_dict,
|
||||
"generation_metadata": {
|
||||
"status": "success",
|
||||
"rule_ids": rule_ids,
|
||||
"goals": goals
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating plan: {str(e)}")
|
||||
|
||||
async def get_plan_evolution_history(self, plan_id: int) -> List[Dict]:
|
||||
"""Get full evolution history for a plan."""
|
||||
try:
|
||||
evolution_service = PlanEvolutionService(self.db)
|
||||
plans = await evolution_service.get_plan_evolution_history(plan_id)
|
||||
|
||||
if not plans:
|
||||
raise Exception("Plan not found")
|
||||
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"version": p.version,
|
||||
"parent_plan_id": p.parent_plan_id,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"plan_data": p.jsonb_plan
|
||||
} for p in plans
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching plan evolution: {str(e)}")
|
||||
|
||||
async def get_current_plan(self) -> Optional[Dict]:
|
||||
"""Get the most recent active plan."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Plan).order_by(desc(Plan.created_at)).limit(1)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
|
||||
if not plan:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": plan.id,
|
||||
"version": plan.version,
|
||||
"parent_plan_id": plan.parent_plan_id,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
||||
"plan_data": plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching current plan: {str(e)}")
|
||||
215
tui/services/route_service.py
Normal file
215
tui/services/route_service.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Route service for TUI application.
|
||||
Manages GPX routes and route visualization without HTTP dependencies.
|
||||
"""
|
||||
import gpxpy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.route import Route
|
||||
from backend.app.models.section import Section
|
||||
|
||||
|
||||
class RouteService:
|
||||
"""Service for route and GPX operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_routes(self) -> List[Dict]:
|
||||
"""Get all routes."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Route).order_by(desc(Route.created_at))
|
||||
)
|
||||
routes = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"total_distance": r.total_distance,
|
||||
"elevation_gain": r.elevation_gain,
|
||||
"gpx_file_path": r.gpx_file_path,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in routes
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching routes: {str(e)}")
|
||||
|
||||
async def get_route(self, route_id: int) -> Optional[Dict]:
|
||||
"""Get a specific route by ID."""
|
||||
try:
|
||||
route = await self.db.get(Route, route_id)
|
||||
if not route:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": route.id,
|
||||
"name": route.name,
|
||||
"description": route.description,
|
||||
"total_distance": route.total_distance,
|
||||
"elevation_gain": route.elevation_gain,
|
||||
"gpx_file_path": route.gpx_file_path,
|
||||
"created_at": route.created_at.isoformat() if route.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching route {route_id}: {str(e)}")
|
||||
|
||||
async def load_gpx_file(self, file_path: str) -> Dict:
|
||||
"""Load and parse GPX file."""
|
||||
try:
|
||||
gpx_path = Path(file_path)
|
||||
if not gpx_path.exists():
|
||||
raise Exception(f"GPX file not found: {file_path}")
|
||||
|
||||
with open(gpx_path, 'r', encoding='utf-8') as gpx_file:
|
||||
gpx = gpxpy.parse(gpx_file)
|
||||
|
||||
# Extract track points
|
||||
track_points = []
|
||||
total_distance = 0
|
||||
min_elevation = float('inf')
|
||||
max_elevation = float('-inf')
|
||||
|
||||
for track in gpx.tracks:
|
||||
for segment in track.segments:
|
||||
for point in segment.points:
|
||||
track_points.append({
|
||||
"lat": point.latitude,
|
||||
"lon": point.longitude,
|
||||
"ele": point.elevation if point.elevation else 0,
|
||||
"time": point.time.isoformat() if point.time else None
|
||||
})
|
||||
|
||||
if point.elevation:
|
||||
min_elevation = min(min_elevation, point.elevation)
|
||||
max_elevation = max(max_elevation, point.elevation)
|
||||
|
||||
# Calculate total distance and elevation gain
|
||||
if track_points:
|
||||
total_distance = self._calculate_total_distance(track_points)
|
||||
elevation_gain = max_elevation - min_elevation if max_elevation != float('-inf') else 0
|
||||
|
||||
return {
|
||||
"name": gpx_path.stem,
|
||||
"total_distance": total_distance,
|
||||
"elevation_gain": elevation_gain,
|
||||
"track_points": track_points,
|
||||
"gpx_file_path": file_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error loading GPX file {file_path}: {str(e)}")
|
||||
|
||||
def _calculate_total_distance(self, track_points: List[Dict]) -> float:
|
||||
"""Calculate total distance from track points."""
|
||||
if len(track_points) < 2:
|
||||
return 0
|
||||
|
||||
total_distance = 0
|
||||
for i in range(1, len(track_points)):
|
||||
prev_point = track_points[i-1]
|
||||
curr_point = track_points[i]
|
||||
|
||||
# Simple haversine distance calculation
|
||||
distance = self._haversine_distance(
|
||||
prev_point["lat"], prev_point["lon"],
|
||||
curr_point["lat"], curr_point["lon"]
|
||||
)
|
||||
total_distance += distance
|
||||
|
||||
return total_distance
|
||||
|
||||
def _haversine_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance between two points using haversine formula."""
|
||||
import math
|
||||
|
||||
R = 6371000 # Earth's radius in meters
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
delta_lat = math.radians(lat2 - lat1)
|
||||
delta_lon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(delta_lat / 2) ** 2 +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) *
|
||||
math.sin(delta_lon / 2) ** 2)
|
||||
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
async def create_route(self, name: str, description: str, gpx_file_path: str) -> Dict:
|
||||
"""Create a new route from GPX data."""
|
||||
try:
|
||||
# Load GPX data
|
||||
gpx_data = await self.load_gpx_file(gpx_file_path)
|
||||
|
||||
# Create route record
|
||||
db_route = Route(
|
||||
name=name,
|
||||
description=description,
|
||||
total_distance=gpx_data["total_distance"],
|
||||
elevation_gain=gpx_data["elevation_gain"],
|
||||
gpx_file_path=gpx_file_path
|
||||
)
|
||||
self.db.add(db_route)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_route)
|
||||
|
||||
return {
|
||||
"id": db_route.id,
|
||||
"name": db_route.name,
|
||||
"description": db_route.description,
|
||||
"total_distance": db_route.total_distance,
|
||||
"elevation_gain": db_route.elevation_gain,
|
||||
"gpx_file_path": db_route.gpx_file_path,
|
||||
"created_at": db_route.created_at.isoformat() if db_route.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating route: {str(e)}")
|
||||
|
||||
async def delete_route(self, route_id: int) -> Dict:
|
||||
"""Delete a route."""
|
||||
try:
|
||||
route = await self.db.get(Route, route_id)
|
||||
if not route:
|
||||
raise Exception("Route not found")
|
||||
|
||||
await self.db.delete(route)
|
||||
await self.db.commit()
|
||||
|
||||
return {"message": "Route deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error deleting route: {str(e)}")
|
||||
|
||||
async def get_route_sections(self, route_id: int) -> List[Dict]:
|
||||
"""Get sections for a specific route."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Section).where(Section.route_id == route_id)
|
||||
)
|
||||
sections = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"route_id": s.route_id,
|
||||
"gpx_file_path": s.gpx_file_path,
|
||||
"distance_m": s.distance_m,
|
||||
"grade_avg": s.grade_avg,
|
||||
"min_gear": s.min_gear,
|
||||
"est_time_minutes": s.est_time_minutes
|
||||
} for s in sections
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching route sections: {str(e)}")
|
||||
244
tui/services/rule_service.py
Normal file
244
tui/services/rule_service.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Rule service for TUI application.
|
||||
Manages training rules without HTTP dependencies.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.rule import Rule
|
||||
|
||||
|
||||
class RuleService:
|
||||
"""Service for training rule operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_rules(self) -> List[Dict]:
|
||||
"""Get all training rules."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Rule).order_by(desc(Rule.created_at))
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"user_defined": r.user_defined,
|
||||
"rule_text": r.rule_text,
|
||||
"version": r.version,
|
||||
"parent_rule_id": r.parent_rule_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in rules
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching rules: {str(e)}")
|
||||
|
||||
async def get_rule(self, rule_id: int) -> Optional[Dict]:
|
||||
"""Get a specific rule by ID."""
|
||||
try:
|
||||
rule = await self.db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": rule.id,
|
||||
"name": rule.name,
|
||||
"description": rule.description,
|
||||
"user_defined": rule.user_defined,
|
||||
"rule_text": rule.rule_text,
|
||||
"version": rule.version,
|
||||
"parent_rule_id": rule.parent_rule_id,
|
||||
"created_at": rule.created_at.isoformat() if rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching rule {rule_id}: {str(e)}")
|
||||
|
||||
async def create_rule(
|
||||
self,
|
||||
name: str,
|
||||
rule_text: str,
|
||||
description: Optional[str] = None,
|
||||
user_defined: bool = True,
|
||||
version: int = 1,
|
||||
parent_rule_id: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""Create a new training rule."""
|
||||
try:
|
||||
db_rule = Rule(
|
||||
name=name,
|
||||
description=description,
|
||||
user_defined=user_defined,
|
||||
rule_text=rule_text,
|
||||
version=version,
|
||||
parent_rule_id=parent_rule_id
|
||||
)
|
||||
self.db.add(db_rule)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_rule)
|
||||
|
||||
return {
|
||||
"id": db_rule.id,
|
||||
"name": db_rule.name,
|
||||
"description": db_rule.description,
|
||||
"user_defined": db_rule.user_defined,
|
||||
"rule_text": db_rule.rule_text,
|
||||
"version": db_rule.version,
|
||||
"parent_rule_id": db_rule.parent_rule_id,
|
||||
"created_at": db_rule.created_at.isoformat() if db_rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating rule: {str(e)}")
|
||||
|
||||
async def update_rule(
|
||||
self,
|
||||
rule_id: int,
|
||||
name: Optional[str] = None,
|
||||
rule_text: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
version: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""Update an existing rule."""
|
||||
try:
|
||||
db_rule = await self.db.get(Rule, rule_id)
|
||||
if not db_rule:
|
||||
raise Exception("Rule not found")
|
||||
|
||||
if name is not None:
|
||||
db_rule.name = name
|
||||
if rule_text is not None:
|
||||
db_rule.rule_text = rule_text
|
||||
if description is not None:
|
||||
db_rule.description = description
|
||||
if version is not None:
|
||||
db_rule.version = version
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_rule)
|
||||
|
||||
return {
|
||||
"id": db_rule.id,
|
||||
"name": db_rule.name,
|
||||
"description": db_rule.description,
|
||||
"user_defined": db_rule.user_defined,
|
||||
"rule_text": db_rule.rule_text,
|
||||
"version": db_rule.version,
|
||||
"parent_rule_id": db_rule.parent_rule_id,
|
||||
"created_at": db_rule.created_at.isoformat() if db_rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error updating rule: {str(e)}")
|
||||
|
||||
async def delete_rule(self, rule_id: int) -> Dict:
|
||||
"""Delete a rule."""
|
||||
try:
|
||||
rule = await self.db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
raise Exception("Rule not found")
|
||||
|
||||
await self.db.delete(rule)
|
||||
await self.db.commit()
|
||||
|
||||
return {"message": "Rule deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error deleting rule: {str(e)}")
|
||||
|
||||
async def get_user_defined_rules(self) -> List[Dict]:
|
||||
"""Get only user-defined rules."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Rule)
|
||||
.where(Rule.user_defined == True)
|
||||
.order_by(desc(Rule.created_at))
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"rule_text": r.rule_text,
|
||||
"version": r.version,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in rules
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching user-defined rules: {str(e)}")
|
||||
|
||||
async def get_system_rules(self) -> List[Dict]:
|
||||
"""Get only system-defined rules."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Rule)
|
||||
.where(Rule.user_defined == False)
|
||||
.order_by(desc(Rule.created_at))
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"rule_text": r.rule_text,
|
||||
"version": r.version,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in rules
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching system rules: {str(e)}")
|
||||
|
||||
async def create_rule_version(self, parent_rule_id: int, rule_text: str, description: Optional[str] = None) -> Dict:
|
||||
"""Create a new version of an existing rule."""
|
||||
try:
|
||||
parent_rule = await self.db.get(Rule, parent_rule_id)
|
||||
if not parent_rule:
|
||||
raise Exception("Parent rule not found")
|
||||
|
||||
# Get the highest version number for this rule family
|
||||
result = await self.db.execute(
|
||||
select(Rule.version)
|
||||
.where(Rule.parent_rule_id == parent_rule_id)
|
||||
.order_by(desc(Rule.version))
|
||||
.limit(1)
|
||||
)
|
||||
max_version = result.scalar_one_or_none() or parent_rule.version
|
||||
new_version = max_version + 1
|
||||
|
||||
new_rule = Rule(
|
||||
name=parent_rule.name,
|
||||
description=description or parent_rule.description,
|
||||
user_defined=parent_rule.user_defined,
|
||||
rule_text=rule_text,
|
||||
version=new_version,
|
||||
parent_rule_id=parent_rule_id
|
||||
)
|
||||
self.db.add(new_rule)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(new_rule)
|
||||
|
||||
return {
|
||||
"id": new_rule.id,
|
||||
"name": new_rule.name,
|
||||
"description": new_rule.description,
|
||||
"user_defined": new_rule.user_defined,
|
||||
"rule_text": new_rule.rule_text,
|
||||
"version": new_rule.version,
|
||||
"parent_rule_id": new_rule.parent_rule_id,
|
||||
"created_at": new_rule.created_at.isoformat() if new_rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating rule version: {str(e)}")
|
||||
202
tui/services/workout_service.py
Normal file
202
tui/services/workout_service.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Workout service for TUI application.
|
||||
Manages workout data, analysis, and Garmin sync without HTTP dependencies.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.analysis import Analysis
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.services.workout_sync import WorkoutSyncService
|
||||
from backend.app.services.ai_service import AIService
|
||||
|
||||
|
||||
class WorkoutService:
|
||||
"""Service for workout operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_workouts(self, limit: Optional[int] = None) -> List[Dict]:
|
||||
"""Get all workouts."""
|
||||
try:
|
||||
query = select(Workout).order_by(desc(Workout.start_time))
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
workouts = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"garmin_activity_id": w.garmin_activity_id,
|
||||
"activity_type": w.activity_type,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"duration_seconds": w.duration_seconds,
|
||||
"distance_m": w.distance_m,
|
||||
"avg_hr": w.avg_hr,
|
||||
"max_hr": w.max_hr,
|
||||
"avg_power": w.avg_power,
|
||||
"max_power": w.max_power,
|
||||
"avg_cadence": w.avg_cadence,
|
||||
"elevation_gain_m": w.elevation_gain_m
|
||||
} for w in workouts
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching workouts: {str(e)}")
|
||||
|
||||
async def get_workout(self, workout_id: int) -> Optional[Dict]:
|
||||
"""Get a specific workout by ID."""
|
||||
try:
|
||||
workout = await self.db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": workout.id,
|
||||
"garmin_activity_id": workout.garmin_activity_id,
|
||||
"activity_type": workout.activity_type,
|
||||
"start_time": workout.start_time.isoformat() if workout.start_time else None,
|
||||
"duration_seconds": workout.duration_seconds,
|
||||
"distance_m": workout.distance_m,
|
||||
"avg_hr": workout.avg_hr,
|
||||
"max_hr": workout.max_hr,
|
||||
"avg_power": workout.avg_power,
|
||||
"max_power": workout.max_power,
|
||||
"avg_cadence": workout.avg_cadence,
|
||||
"elevation_gain_m": workout.elevation_gain_m,
|
||||
"metrics": workout.metrics
|
||||
}
|
||||
|
||||
except Exception as 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)}")
|
||||
Reference in New Issue
Block a user