831 lines
34 KiB
Python
831 lines
34 KiB
Python
from fastapi import APIRouter, Query, Response, HTTPException, Depends, BackgroundTasks
|
|
from pydantic import BaseModel
|
|
from typing import List, Optional, Dict, Any
|
|
from sqlalchemy import func
|
|
from ..models.activity import Activity
|
|
import logging
|
|
from ..services.postgresql_manager import PostgreSQLManager
|
|
from sqlalchemy.orm import Session
|
|
from ..utils.config import config
|
|
|
|
# New Sync Imports
|
|
from ..services.job_manager import job_manager
|
|
from ..models.activity_state import GarminActivityState
|
|
from datetime import datetime
|
|
from ..services.parsers import extract_points_from_file
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def get_db():
|
|
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
|
with db_manager.get_db_session() as session:
|
|
yield session
|
|
|
|
class BikeSetupInfo(BaseModel):
|
|
id: int
|
|
frame: str
|
|
chainring: int
|
|
rear_cog: int
|
|
name: Optional[str] = None
|
|
|
|
class ActivityResponse(BaseModel):
|
|
id: Optional[int] = None
|
|
garmin_activity_id: Optional[str] = None
|
|
activity_name: Optional[str] = None
|
|
activity_type: Optional[str] = None
|
|
start_time: Optional[str] = None
|
|
duration: Optional[int] = None
|
|
# file_path removed since we store in DB
|
|
file_type: Optional[str] = None
|
|
download_status: Optional[str] = None
|
|
downloaded_at: Optional[str] = None
|
|
bike_setup: Optional[BikeSetupInfo] = None
|
|
|
|
class ActivityDetailResponse(ActivityResponse):
|
|
distance: Optional[float] = None
|
|
calories: Optional[float] = None
|
|
avg_hr: Optional[int] = None
|
|
max_hr: Optional[int] = None
|
|
avg_speed: Optional[float] = None
|
|
max_speed: Optional[float] = None
|
|
elevation_gain: Optional[float] = None
|
|
elevation_loss: Optional[float] = None
|
|
avg_cadence: Optional[int] = None
|
|
max_cadence: Optional[int] = None
|
|
steps: Optional[int] = None
|
|
aerobic_te: Optional[float] = None
|
|
anaerobic_te: Optional[float] = None
|
|
avg_power: Optional[int] = None
|
|
max_power: Optional[int] = None
|
|
norm_power: Optional[int] = None
|
|
tss: Optional[float] = None
|
|
vo2_max: Optional[float] = None
|
|
|
|
|
|
@router.get("/activities/list", response_model=List[ActivityResponse])
|
|
async def list_activities(
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Return metadata for all scanned activities, indicating download status.
|
|
"""
|
|
try:
|
|
logger.info(f"Listing activities with limit={limit}, offset={offset}")
|
|
|
|
# Query GarminActivityState (all known activities)
|
|
# Left join with Activity to get file status
|
|
|
|
results = (
|
|
db.query(GarminActivityState, Activity)
|
|
.outerjoin(Activity, GarminActivityState.garmin_activity_id == Activity.garmin_activity_id)
|
|
.order_by(GarminActivityState.start_time.desc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
|
|
activity_responses = []
|
|
for state, activity in results:
|
|
# Determine logic
|
|
# If activity exists in 'Activity' table, use its details?
|
|
# Or prefer GarminActivityState metadata?
|
|
# State metadata is from scan (Garth). Activity is from file parse (db import).
|
|
# Usually Activity data is richer IF downloaded.
|
|
|
|
is_downloaded = (
|
|
activity is not None and
|
|
activity.download_status == 'downloaded' and
|
|
activity.file_content is not None
|
|
)
|
|
|
|
download_status = 'downloaded' if is_downloaded else 'pending'
|
|
# Or use state.sync_status? state.sync_status is 'new', 'synced'.
|
|
# 'synced' usually means downloaded.
|
|
|
|
# Construct response
|
|
activity_responses.append(
|
|
ActivityResponse(
|
|
id=activity.id if activity else None,
|
|
garmin_activity_id=state.garmin_activity_id,
|
|
activity_name=state.activity_name,
|
|
activity_type=state.activity_type,
|
|
start_time=state.start_time.isoformat() if state.start_time else None,
|
|
duration=activity.duration if activity else None, # Duration might only be in file parse? Or scan could get it? Scan currently doesn't fetch duration.
|
|
file_type=activity.file_type if activity else None,
|
|
download_status=download_status,
|
|
downloaded_at=activity.downloaded_at.isoformat() if (activity and activity.downloaded_at) else None,
|
|
bike_setup=BikeSetupInfo(
|
|
id=activity.bike_setup.id,
|
|
frame=activity.bike_setup.frame,
|
|
chainring=activity.bike_setup.chainring,
|
|
rear_cog=activity.bike_setup.rear_cog,
|
|
name=activity.bike_setup.name
|
|
) if (activity and activity.bike_setup) else None
|
|
)
|
|
)
|
|
|
|
logger.info(f"Returning {len(activity_responses)} activities")
|
|
return activity_responses
|
|
except Exception as e:
|
|
logger.error(f"Error in list_activities: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error listing activities: {str(e)}")
|
|
|
|
@router.get("/activities/query", response_model=List[ActivityResponse])
|
|
async def query_activities(
|
|
activity_type: Optional[str] = Query(None),
|
|
start_date: Optional[str] = Query(None),
|
|
end_date: Optional[str] = Query(None),
|
|
download_status: Optional[str] = Query(None),
|
|
bike_setup_id: Optional[int] = Query(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Allow advanced filtering of activities.
|
|
"""
|
|
try:
|
|
logger.info(f"Querying activities - type: {activity_type}, start: {start_date}, end: {end_date}, status: {download_status}")
|
|
|
|
# Start building the query
|
|
query = db.query(Activity)
|
|
|
|
# Apply filters based on parameters
|
|
if activity_type:
|
|
if activity_type == 'cycling':
|
|
# Match outdoor cycling types
|
|
# Using OR filtering for various sub-types
|
|
from sqlalchemy import or_
|
|
query = query.filter(or_(
|
|
Activity.activity_type == 'cycling',
|
|
Activity.activity_type == 'road_biking',
|
|
Activity.activity_type == 'mountain_biking',
|
|
Activity.activity_type == 'gravel_cycling',
|
|
Activity.activity_type == 'cyclocross',
|
|
Activity.activity_type == 'track_cycling',
|
|
Activity.activity_type == 'commuting'
|
|
))
|
|
else:
|
|
query = query.filter(Activity.activity_type == activity_type)
|
|
|
|
if start_date:
|
|
from datetime import datetime
|
|
start_dt = datetime.fromisoformat(start_date)
|
|
query = query.filter(Activity.start_time >= start_dt)
|
|
|
|
if end_date:
|
|
from datetime import datetime
|
|
end_dt = datetime.fromisoformat(end_date)
|
|
query = query.filter(Activity.start_time <= end_dt)
|
|
|
|
if download_status:
|
|
query = query.filter(Activity.download_status == download_status)
|
|
|
|
if bike_setup_id:
|
|
query = query.filter(Activity.bike_setup_id == bike_setup_id)
|
|
|
|
# Execute the query
|
|
activities = query.all()
|
|
|
|
# Convert SQLAlchemy objects to Pydantic models
|
|
activity_responses = []
|
|
for activity in activities:
|
|
activity_responses.append(
|
|
ActivityResponse(
|
|
id=activity.id,
|
|
garmin_activity_id=activity.garmin_activity_id,
|
|
activity_name=activity.activity_name,
|
|
activity_type=activity.activity_type,
|
|
start_time=activity.start_time.isoformat() if activity.start_time else None,
|
|
duration=activity.duration,
|
|
file_type=activity.file_type,
|
|
download_status=activity.download_status,
|
|
downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None,
|
|
bike_setup=BikeSetupInfo(
|
|
id=activity.bike_setup.id,
|
|
frame=activity.bike_setup.frame,
|
|
chainring=activity.bike_setup.chainring,
|
|
rear_cog=activity.bike_setup.rear_cog,
|
|
name=activity.bike_setup.name
|
|
) if activity.bike_setup else None
|
|
)
|
|
)
|
|
|
|
logger.info(f"Returning {len(activity_responses)} filtered activities")
|
|
return activity_responses
|
|
except Exception as e:
|
|
logger.error(f"Error in query_activities: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=f"Error querying activities: {str(e)}")
|
|
|
|
@router.get("/activities/download/{activity_id}")
|
|
async def download_activity(activity_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Serve the stored activity file from the database.
|
|
"""
|
|
try:
|
|
logger.info(f"Downloading activity with ID: {activity_id}")
|
|
|
|
# Find the activity in the database
|
|
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
|
|
if not activity:
|
|
raise HTTPException(status_code=404, detail=f"Activity with ID {activity_id} not found")
|
|
|
|
if not activity.file_content:
|
|
raise HTTPException(status_code=404, detail=f"No file content available for activity {activity_id}")
|
|
|
|
if activity.download_status != 'downloaded':
|
|
raise HTTPException(status_code=400, detail=f"File for activity {activity_id} is not ready for download (status: {activity.download_status})")
|
|
|
|
# Determine the appropriate content type based on the file type
|
|
content_type_map = {
|
|
'tcx': 'application/vnd.garmin.tcx+xml',
|
|
'gpx': 'application/gpx+xml',
|
|
'fit': 'application/octet-stream' # FIT files are binary
|
|
}
|
|
|
|
content_type = content_type_map.get(activity.file_type, 'application/octet-stream')
|
|
filename = f"activity_{activity_id}.{activity.file_type}"
|
|
|
|
logger.info(f"Returning file for activity {activity_id} with content type {content_type}")
|
|
return Response(
|
|
content=activity.file_content,
|
|
media_type=content_type,
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
"Content-Length": str(len(activity.file_content))
|
|
}
|
|
)
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions as-is
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error downloading activity: {str(e)}")
|
|
|
|
@router.get("/activities/{activity_id}/details", response_model=ActivityDetailResponse)
|
|
async def get_activity_details(activity_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Get full details for a specific activity.
|
|
"""
|
|
try:
|
|
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
if not activity:
|
|
raise HTTPException(status_code=404, detail="Activity not found")
|
|
|
|
# Fallback: Extraction from file if DB fields are missing
|
|
overrides = {}
|
|
if activity.file_content and (activity.distance is None or activity.elevation_gain is None or activity.avg_hr is None):
|
|
try:
|
|
if activity.file_type == 'fit':
|
|
overrides = _extract_summary_from_fit(activity.file_content)
|
|
elif activity.file_type == 'tcx':
|
|
# overrides = _extract_summary_from_tcx(activity.file_content) # Optional TODO
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"Failed to extract summary from file: {e}")
|
|
|
|
# Helper to merge DB value or Override
|
|
def val(attr, key):
|
|
v = getattr(activity, attr)
|
|
if v is not None: return v
|
|
return overrides.get(key)
|
|
|
|
return ActivityDetailResponse(
|
|
id=activity.id,
|
|
garmin_activity_id=activity.garmin_activity_id,
|
|
activity_name=activity.activity_name,
|
|
activity_type=activity.activity_type,
|
|
start_time=activity.start_time.isoformat() if activity.start_time else None,
|
|
duration=val('duration', 'total_timer_time'),
|
|
file_type=activity.file_type,
|
|
download_status=activity.download_status,
|
|
downloaded_at=activity.downloaded_at.isoformat() if activity.downloaded_at else None,
|
|
# Extended metrics
|
|
distance=val('distance', 'total_distance'),
|
|
calories=val('calories', 'total_calories'),
|
|
avg_hr=val('avg_hr', 'avg_heart_rate'),
|
|
max_hr=val('max_hr', 'max_heart_rate'),
|
|
avg_speed=val('avg_speed', 'enhanced_avg_speed'), # fallback to avg_speed handled in extractor
|
|
max_speed=val('max_speed', 'enhanced_max_speed'),
|
|
elevation_gain=val('elevation_gain', 'total_ascent'),
|
|
elevation_loss=val('elevation_loss', 'total_descent'),
|
|
avg_cadence=val('avg_cadence', 'avg_cadence'),
|
|
max_cadence=val('max_cadence', 'max_cadence'),
|
|
steps=activity.steps, # No session step count usually
|
|
aerobic_te=val('aerobic_te', 'total_training_effect'),
|
|
anaerobic_te=val('anaerobic_te', 'total_anaerobic_training_effect'),
|
|
avg_power=val('avg_power', 'avg_power'),
|
|
max_power=val('max_power', 'max_power'),
|
|
norm_power=val('norm_power', 'normalized_power'),
|
|
tss=val('tss', 'training_stress_score'),
|
|
vo2_max=activity.vo2_max, # Usually not in simple session msg directly but maybe
|
|
bike_setup=BikeSetupInfo(
|
|
id=activity.bike_setup.id,
|
|
frame=activity.bike_setup.frame,
|
|
chainring=activity.bike_setup.chainring,
|
|
rear_cog=activity.bike_setup.rear_cog,
|
|
name=activity.bike_setup.name
|
|
) if activity.bike_setup else None
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting activity details: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# Import necessary auth dependencies
|
|
from ..models.api_token import APIToken
|
|
import garth
|
|
import json
|
|
from garth.auth_tokens import OAuth1Token, OAuth2Token
|
|
|
|
def _verify_garmin_session(db: Session):
|
|
"""Helper to load token from DB and verify session with Garmin (Inline for now)."""
|
|
token_record = db.query(APIToken).filter_by(token_type='garmin').first()
|
|
if not (token_record and token_record.garth_oauth1_token and token_record.garth_oauth2_token):
|
|
return False
|
|
|
|
try:
|
|
oauth1_dict = json.loads(token_record.garth_oauth1_token)
|
|
oauth2_dict = json.loads(token_record.garth_oauth2_token)
|
|
|
|
domain = oauth1_dict.get('domain')
|
|
if domain:
|
|
garth.configure(domain=domain)
|
|
|
|
garth.client.oauth1_token = OAuth1Token(**oauth1_dict)
|
|
garth.client.oauth2_token = OAuth2Token(**oauth2_dict)
|
|
|
|
# Simple check or full profile get?
|
|
# garth.UserProfile.get() # strict check
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Garth session load failed: {e}")
|
|
return False
|
|
|
|
@router.post("/activities/{activity_id}/redownload")
|
|
async def redownload_activity_endpoint(activity_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Trigger a re-download of the activity file from Garmin.
|
|
"""
|
|
try:
|
|
logger.info(f"Request to redownload activity {activity_id}")
|
|
|
|
from ..services.garmin.client import GarminClient
|
|
from ..services.sync_app import SyncApp
|
|
|
|
# Verify Auth
|
|
if not _verify_garmin_session(db):
|
|
raise HTTPException(status_code=401, detail="Garmin not authenticated or tokens invalid. Please go to Setup.")
|
|
|
|
garmin_client = GarminClient()
|
|
# Double check connection?
|
|
if not garmin_client.check_connection():
|
|
# Try refreshing? For now just fail if token load wasn't enough
|
|
# But usually token load is enough.
|
|
pass
|
|
|
|
sync_app = SyncApp(db, garmin_client)
|
|
|
|
success = sync_app.redownload_activity(activity_id)
|
|
|
|
if success:
|
|
# Trigger bike matching
|
|
try:
|
|
from ..services.bike_matching import process_activity_matching
|
|
|
|
# Fetch fresh activity object using new session logic or flush/commit handled by sync_app
|
|
# Just query by garmin_id
|
|
act_obj = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
if act_obj:
|
|
process_activity_matching(db, act_obj.id)
|
|
logger.info(f"Retriggered bike match for {activity_id} after redownload")
|
|
except Exception as match_err:
|
|
logger.error(f"Error matching bike after redownload: {match_err}")
|
|
|
|
return {"message": f"Successfully redownloaded and matched activity {activity_id}", "status": "success"}
|
|
else:
|
|
raise HTTPException(status_code=500, detail="Failed to redownload activity. Check logs for details.")
|
|
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in redownload_activity_endpoint: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error processing redownload: {str(e)}")
|
|
|
|
# New Sync Endpoints
|
|
|
|
class BikeMatchUpdate(BaseModel):
|
|
bike_setup_id: Optional[int] = None
|
|
manual_override: bool = True
|
|
|
|
@router.put("/activities/{activity_id}/bike")
|
|
async def update_activity_bike(activity_id: str, update: BikeMatchUpdate, db: Session = Depends(get_db)):
|
|
"""
|
|
Manually update the bike setup for an activity.
|
|
Sets bike_match_confidence to 2.0 to indicate manual override.
|
|
"""
|
|
try:
|
|
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
if not activity:
|
|
raise HTTPException(status_code=404, detail="Activity not found")
|
|
|
|
# Verify bike setup exists if provided
|
|
if update.bike_setup_id:
|
|
from ..models.bike_setup import BikeSetup
|
|
setup = db.query(BikeSetup).filter(BikeSetup.id == update.bike_setup_id).first()
|
|
if not setup:
|
|
raise HTTPException(status_code=404, detail="Bike Setup not found")
|
|
|
|
activity.bike_setup_id = setup.id
|
|
activity.bike_match_confidence = 2.0 # Manual Override
|
|
logger.info(f"Manual bike override for {activity_id} to setup {setup.id}")
|
|
else:
|
|
# Clear setup
|
|
activity.bike_setup_id = None
|
|
activity.bike_match_confidence = 2.0 # Manual Clear
|
|
logger.info(f"Manual bike override for {activity_id} to cleared")
|
|
|
|
db.commit()
|
|
return {"message": "Bike setup updated successfully", "status": "success"}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating activity bike: {e}")
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
def run_scan_job(job_id: str, days_back: int, db_session_factory):
|
|
"""Background task wrapper for scan"""
|
|
try:
|
|
from ..services.garmin.client import GarminClient
|
|
from ..services.sync_app import SyncApp
|
|
except Exception as e:
|
|
logger.error(f"Import error in background job: {e}")
|
|
job_manager.fail_job(job_id, f"Import error: {str(e)}")
|
|
return
|
|
|
|
try:
|
|
with db_session_factory() as db:
|
|
garmin_client = GarminClient()
|
|
sync_app = SyncApp(db, garmin_client)
|
|
|
|
job_manager.update_job(job_id, status="running", progress=0)
|
|
sync_app.scan_activities(days_back=days_back)
|
|
job_manager.complete_job(job_id)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Scan job failed: {e}")
|
|
job_manager.fail_job(job_id, str(e))
|
|
|
|
def run_sync_job(job_id: str, limit: int, db_session_factory):
|
|
"""Background task wrapper for sync pending"""
|
|
try:
|
|
from ..services.garmin.client import GarminClient
|
|
from ..services.sync_app import SyncApp
|
|
except Exception as e:
|
|
logger.error(f"Import error in background job: {e}")
|
|
job_manager.fail_job(job_id, f"Import error: {str(e)}")
|
|
return
|
|
|
|
with db_session_factory() as db:
|
|
try:
|
|
garmin_client = GarminClient()
|
|
sync_app = SyncApp(db, garmin_client)
|
|
|
|
# sync_pending_activities handles job updates
|
|
sync_app.sync_pending_activities(limit=limit, job_id=job_id)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Sync job failed: {e}")
|
|
job_manager.fail_job(job_id, str(e))
|
|
|
|
|
|
@router.post("/activities/sync/scan")
|
|
async def scan_activities_trigger(
|
|
background_tasks: BackgroundTasks,
|
|
days_back: int = Query(30, description="Number of days to scan back for new activities")
|
|
):
|
|
"""Trigger background scan of metadata"""
|
|
job_id = job_manager.create_job("scan_activities")
|
|
|
|
# We need a new session for the background task
|
|
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
|
# Use context manager in wrapper
|
|
|
|
background_tasks.add_task(run_scan_job, job_id, days_back, db_manager.get_db_session)
|
|
return {"job_id": job_id, "status": "started"}
|
|
|
|
@router.post("/activities/sync/pending")
|
|
async def sync_pending_trigger(
|
|
background_tasks: BackgroundTasks,
|
|
limit: Optional[int] = Query(None, description="Limit number of activities to sync")
|
|
):
|
|
"""Trigger background sync of pending activities"""
|
|
job_id = job_manager.create_job("sync_pending_activities")
|
|
|
|
db_manager = PostgreSQLManager(config.DATABASE_URL)
|
|
background_tasks.add_task(run_sync_job, job_id, limit, db_manager.get_db_session)
|
|
return {"job_id": job_id, "status": "started"}
|
|
|
|
@router.get("/activities/sync/status")
|
|
async def get_sync_status_summary(db: Session = Depends(get_db)):
|
|
"""Get counts of activities by sync status"""
|
|
try:
|
|
from sqlalchemy import func
|
|
stats = db.query(
|
|
GarminActivityState.sync_status,
|
|
func.count(GarminActivityState.garmin_activity_id)
|
|
).group_by(GarminActivityState.sync_status).all()
|
|
|
|
return {s[0]: s[1] for s in stats}
|
|
except Exception as e:
|
|
logger.error(f"Error getting sync status: {e}")
|
|
return {}
|
|
|
|
|
|
|
|
|
|
@router.get("/activities/{activity_id}/geojson")
|
|
async def get_activity_geojson(activity_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Return GeoJSON LineString for the activity track.
|
|
"""
|
|
try:
|
|
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
if not activity or not activity.file_content:
|
|
raise HTTPException(status_code=404, detail="Activity or file content not found")
|
|
|
|
points = []
|
|
if activity.file_type in ['fit', 'tcx']:
|
|
points = extract_points_from_file(activity.file_content, activity.file_type)
|
|
else:
|
|
logger.warning(f"Unsupported file type for map: {activity.file_type}")
|
|
|
|
if not points:
|
|
return {"type": "FeatureCollection", "features": []}
|
|
|
|
return {
|
|
"type": "FeatureCollection",
|
|
"features": [{
|
|
"type": "Feature",
|
|
"properties": {
|
|
"color": "red"
|
|
},
|
|
"geometry": {
|
|
"type": "LineString",
|
|
"coordinates": points
|
|
}
|
|
}]
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error generating GeoJSON: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
def _extract_streams_from_fit(file_content: bytes) -> Dict[str, List[Any]]:
|
|
streams = {
|
|
"time": [],
|
|
"heart_rate": [],
|
|
"power": [],
|
|
"altitude": [],
|
|
"speed": [],
|
|
"cadence": []
|
|
}
|
|
try:
|
|
start_time = None
|
|
with io.BytesIO(file_content) as f:
|
|
with fitdecode.FitReader(f) as fit:
|
|
for frame in fit:
|
|
if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record':
|
|
timestamp = frame.get_value('timestamp')
|
|
if not start_time and timestamp:
|
|
start_time = timestamp
|
|
|
|
if timestamp and start_time:
|
|
# Relative time in seconds
|
|
t = (timestamp - start_time).total_seconds()
|
|
|
|
# Helper to safely get value with fallback
|
|
def get_val(frame, keys):
|
|
for k in keys:
|
|
if frame.has_field(k):
|
|
return frame.get_value(k)
|
|
return None
|
|
|
|
streams["time"].append(t)
|
|
streams["heart_rate"].append(get_val(frame, ['heart_rate']))
|
|
streams["power"].append(get_val(frame, ['power']))
|
|
streams["altitude"].append(get_val(frame, ['enhanced_altitude', 'altitude']))
|
|
streams["speed"].append(get_val(frame, ['enhanced_speed', 'speed'])) # m/s (enhanced is also m/s)
|
|
streams["cadence"].append(get_val(frame, ['cadence']))
|
|
except Exception as e:
|
|
logger.error(f"Error extracting streams from FIT: {e}")
|
|
return streams
|
|
|
|
def _extract_summary_from_fit(file_content: bytes) -> Dict[str, Any]:
|
|
summary = {}
|
|
try:
|
|
with io.BytesIO(file_content) as f:
|
|
with fitdecode.FitReader(f) as fit:
|
|
for frame in fit:
|
|
if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'session':
|
|
# Prefer enhanced fields
|
|
def get(keys):
|
|
for k in keys:
|
|
if frame.has_field(k): return frame.get_value(k)
|
|
return None
|
|
|
|
summary['total_distance'] = get(['total_distance'])
|
|
summary['total_timer_time'] = get(['total_timer_time', 'total_elapsed_time'])
|
|
summary['total_calories'] = get(['total_calories'])
|
|
summary['avg_heart_rate'] = get(['avg_heart_rate'])
|
|
summary['max_heart_rate'] = get(['max_heart_rate'])
|
|
summary['avg_cadence'] = get(['avg_cadence'])
|
|
summary['max_cadence'] = get(['max_cadence'])
|
|
summary['avg_power'] = get(['avg_power'])
|
|
summary['max_power'] = get(['max_power'])
|
|
summary['total_ascent'] = get(['total_ascent'])
|
|
summary['total_descent'] = get(['total_descent'])
|
|
summary['enhanced_avg_speed'] = get(['enhanced_avg_speed', 'avg_speed'])
|
|
summary['enhanced_max_speed'] = get(['enhanced_max_speed', 'max_speed'])
|
|
summary['normalized_power'] = get(['normalized_power'])
|
|
summary['training_stress_score'] = get(['training_stress_score'])
|
|
summary['total_training_effect'] = get(['total_training_effect'])
|
|
summary['total_anaerobic_training_effect'] = get(['total_anaerobic_training_effect'])
|
|
|
|
# Stop after first session message (usually only one per file, or first is summary)
|
|
# Actually FIT can have multiple sessions (multipsport). We'll take the first for now.
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error extraction summary from FIT: {e}")
|
|
return summary
|
|
|
|
def _extract_streams_from_tcx(file_content: bytes) -> Dict[str, List[Any]]:
|
|
streams = {
|
|
"time": [],
|
|
"heart_rate": [],
|
|
"power": [],
|
|
"altitude": [],
|
|
"speed": [],
|
|
"cadence": []
|
|
}
|
|
try:
|
|
root = ET.fromstring(file_content)
|
|
# Namespace strip hack
|
|
start_time = None
|
|
|
|
for trkpt in root.iter():
|
|
if trkpt.tag.endswith('Trackpoint'):
|
|
timestamp_str = None
|
|
hr = None
|
|
pwr = None
|
|
alt = None
|
|
cad = None
|
|
spd = None
|
|
|
|
for child in trkpt.iter():
|
|
if child.tag.endswith('Time'):
|
|
timestamp_str = child.text
|
|
elif child.tag.endswith('AltitudeMeters'):
|
|
try: alt = float(child.text)
|
|
except: pass
|
|
elif child.tag.endswith('HeartRateBpm'):
|
|
for val in child:
|
|
if val.tag.endswith('Value'):
|
|
try: hr = int(val.text)
|
|
except: pass
|
|
elif child.tag.endswith('Cadence'): # Standard TCX cadence
|
|
try: cad = int(child.text)
|
|
except: pass
|
|
elif child.tag.endswith('Extensions'):
|
|
# TPX extensions for speed/power
|
|
for ext in child.iter():
|
|
if ext.tag.endswith('Speed'):
|
|
try: spd = float(ext.text)
|
|
except: pass
|
|
elif ext.tag.endswith('Watts'):
|
|
try: pwr = int(ext.text)
|
|
except: pass
|
|
|
|
if timestamp_str:
|
|
try:
|
|
# TCX time format is ISO8601 usually
|
|
ts = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
|
if not start_time:
|
|
start_time = ts
|
|
|
|
streams["time"].append((ts - start_time).total_seconds())
|
|
streams["heart_rate"].append(hr)
|
|
streams["power"].append(pwr)
|
|
streams["altitude"].append(alt)
|
|
streams["speed"].append(spd)
|
|
streams["cadence"].append(cad)
|
|
except: pass
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting streams from TCX: {e}")
|
|
return streams
|
|
|
|
|
|
@router.get("/activities/{activity_id}/streams")
|
|
async def get_activity_streams(activity_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Return time series data for charts.
|
|
"""
|
|
try:
|
|
activity = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
if not activity or not activity.file_content:
|
|
raise HTTPException(status_code=404, detail="Activity or file content not found")
|
|
|
|
streams = {}
|
|
if activity.file_type == 'fit':
|
|
streams = _extract_streams_from_fit(activity.file_content)
|
|
elif activity.file_type == 'tcx':
|
|
streams = _extract_streams_from_tcx(activity.file_content)
|
|
else:
|
|
logger.warning(f"Unsupported file type for streams: {activity.file_type}")
|
|
|
|
return streams
|
|
except Exception as e:
|
|
logger.error(f"Error getting streams: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.post("/activities/{activity_id}/estimate_power")
|
|
async def estimate_activity_power(activity_id: int, db: Session = Depends(get_db)):
|
|
"""
|
|
Trigger physics-based power estimation.
|
|
"""
|
|
from ..services.power_estimator import PowerEstimatorService
|
|
|
|
try:
|
|
service = PowerEstimatorService(db)
|
|
result = service.estimate_power_for_activity(activity_id)
|
|
return {"message": "Power estimated successfully", "stats": result}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
logger.error(f"Error estimating power: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@router.get("/activities/{activity_id}/navigation")
|
|
async def get_activity_navigation(activity_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Return next/prev activity IDs.
|
|
"""
|
|
try:
|
|
current = db.query(Activity).filter(Activity.garmin_activity_id == activity_id).first()
|
|
if not current:
|
|
raise HTTPException(status_code=404, detail="Activity not found")
|
|
|
|
# Global Prev (Older)
|
|
prev_act = (
|
|
db.query(Activity)
|
|
.filter(Activity.start_time < current.start_time)
|
|
.order_by(Activity.start_time.desc())
|
|
.first()
|
|
)
|
|
|
|
# Global Next (Newer)
|
|
next_act = (
|
|
db.query(Activity)
|
|
.filter(Activity.start_time > current.start_time)
|
|
.order_by(Activity.start_time.asc())
|
|
.first()
|
|
)
|
|
|
|
# Same Type Prev
|
|
prev_type_act = (
|
|
db.query(Activity)
|
|
.filter(Activity.start_time < current.start_time)
|
|
.filter(Activity.activity_type == current.activity_type)
|
|
.order_by(Activity.start_time.desc())
|
|
.first()
|
|
)
|
|
|
|
# Same Type Next
|
|
next_type_act = (
|
|
db.query(Activity)
|
|
.filter(Activity.start_time > current.start_time)
|
|
.filter(Activity.activity_type == current.activity_type)
|
|
.order_by(Activity.start_time.asc())
|
|
.first()
|
|
)
|
|
|
|
return {
|
|
"prev_id": prev_act.garmin_activity_id if prev_act else None,
|
|
"next_id": next_act.garmin_activity_id if next_act else None,
|
|
"prev_type_id": prev_type_act.garmin_activity_id if prev_type_act else None,
|
|
"next_type_id": next_type_act.garmin_activity_id if next_type_act else None
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting navigation: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e)) |