from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc, delete from pydantic import BaseModel from typing import Optional, List from datetime import datetime from app.core.database import get_db from app.core.security import get_current_user from app.models.user import User, Activity, ActivityDataPoint, ActivityLap router = APIRouter() class ActivitySummary(BaseModel): id: int name: str sport_type: str start_time: datetime distance_m: Optional[float] duration_s: Optional[float] elevation_gain_m: Optional[float] avg_heart_rate: Optional[float] avg_cadence: Optional[float] avg_speed_ms: Optional[float] calories: Optional[float] polyline: Optional[str] bounding_box: Optional[dict] hr_zones: Optional[dict] named_route_id: Optional[int] class Config: from_attributes = True class ActivityDetail(ActivitySummary): end_time: Optional[datetime] elevation_loss_m: Optional[float] max_heart_rate: Optional[float] avg_power: Optional[float] normalized_power: Optional[float] max_speed_ms: Optional[float] avg_temperature_c: Optional[float] training_stress_score: Optional[float] vo2max_estimate: Optional[float] class DataPointOut(BaseModel): timestamp: Optional[datetime] latitude: Optional[float] longitude: Optional[float] altitude_m: Optional[float] heart_rate: Optional[float] cadence: Optional[float] speed_ms: Optional[float] power: Optional[float] temperature_c: Optional[float] distance_m: Optional[float] class Config: from_attributes = True class LapOut(BaseModel): lap_number: int start_time: Optional[datetime] duration_s: Optional[float] distance_m: Optional[float] avg_heart_rate: Optional[float] avg_cadence: Optional[float] avg_speed_ms: Optional[float] avg_power: Optional[float] class Config: from_attributes = True @router.get("/", response_model=List[ActivitySummary]) async def list_activities( page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=100), sport_type: Optional[str] = None, from_date: Optional[datetime] = None, to_date: Optional[datetime] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): q = select(Activity).where(Activity.user_id == current_user.id) if sport_type: q = q.where(Activity.sport_type == sport_type) if from_date: q = q.where(Activity.start_time >= from_date) if to_date: q = q.where(Activity.start_time <= to_date) q = q.order_by(desc(Activity.start_time)) q = q.offset((page - 1) * per_page).limit(per_page) result = await db.execute(q) return result.scalars().all() @router.get("/{activity_id}", response_model=ActivityDetail) async def get_activity( activity_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) ) activity = result.scalar_one_or_none() if not activity: raise HTTPException(status_code=404, detail="Activity not found") return activity @router.get("/{activity_id}/data-points", response_model=List[DataPointOut]) async def get_data_points( activity_id: int, downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): act = await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) ) if not act.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Activity not found") q = select(ActivityDataPoint).where( ActivityDataPoint.activity_id == activity_id ).order_by(ActivityDataPoint.timestamp) result = await db.execute(q) points = result.scalars().all() if downsample > 1: points = points[::downsample] return points @router.get("/{activity_id}/laps", response_model=List[LapOut]) async def get_laps( activity_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): act = await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) ) if not act.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Activity not found") result = await db.execute( select(ActivityLap) .where(ActivityLap.activity_id == activity_id) .order_by(ActivityLap.lap_number) ) return result.scalars().all() @router.patch("/{activity_id}/name") async def rename_activity( activity_id: int, body: dict, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) ) activity = result.scalar_one_or_none() if not activity: raise HTTPException(status_code=404, detail="Activity not found") activity.name = body.get("name", activity.name) await db.commit() return {"id": activity_id, "name": activity.name} @router.delete("/{activity_id}", status_code=204) async def delete_activity( activity_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) ) activity = result.scalar_one_or_none() if not activity: raise HTTPException(status_code=404, detail="Activity not found") await db.delete(activity) await db.commit() @router.post("/{activity_id}/reprocess") async def reprocess_activity( activity_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Re-parse the source FIT file and update polyline, data points etc.""" import os result = await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) ) activity = result.scalar_one_or_none() if not activity: raise HTTPException(status_code=404, detail="Activity not found") if not activity.source_file: raise HTTPException(status_code=400, detail="No source file stored for this activity") if not os.path.exists(activity.source_file): raise HTTPException(status_code=404, detail="Source file no longer exists on disk") source_file = activity.source_file source_type = activity.source_type or "fit" await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id)) await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id)) await db.delete(activity) await db.commit() from app.workers.tasks import process_activity_file task = process_activity_file.delay(source_file, current_user.id, source_type) return {"task_id": task.id, "status": "queued"}