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, PersonalRecord 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("/stats/ytd") async def ytd_stats( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return year-to-date distance totals grouped by sport type.""" from datetime import date, timezone year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc) result = await db.execute( select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m")) .where(Activity.user_id == current_user.id, Activity.start_time >= year_start) .group_by(Activity.sport_type) ) rows = result.all() totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows} return { "running_km": round(totals.get("running", 0), 2), "cycling_km": round(totals.get("cycling", 0), 2), "hiking_km": round(totals.get("hiking", 0), 2), "walking_km": round(totals.get("walking", 0), 2), "total_km": round(sum(totals.values()), 2), } @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.get("/{activity_id}/lap-bests") async def get_lap_bests( activity_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Best (fastest) time per lap number across all activities on the same route.""" act = (await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) )).scalar_one_or_none() if not act: raise HTTPException(status_code=404, detail="Activity not found") if not act.named_route_id: return {} # Best per lap number across OTHER activities on the same route, so the # comparison is meaningful (excluding this activity from its own benchmark). rows = (await db.execute( select(ActivityLap.lap_number, func.min(ActivityLap.duration_s)) .join(Activity, Activity.id == ActivityLap.activity_id) .where( Activity.named_route_id == act.named_route_id, Activity.user_id == current_user.id, Activity.id != activity_id, ActivityLap.duration_s.isnot(None), ) .group_by(ActivityLap.lap_number) )).all() return {str(lap_number): best for lap_number, best in rows} @router.get("/{activity_id}/route-leaderboard") async def get_route_leaderboard( activity_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Fastest-time leaderboard across all of this user's activities on the same route. Returns this activity's rank/gap plus the top 10. Null if the activity has no associated route (or no timed efforts to rank).""" act = (await db.execute( select(Activity).where( Activity.id == activity_id, Activity.user_id == current_user.id, ) )).scalar_one_or_none() if not act: raise HTTPException(status_code=404, detail="Activity not found") if not act.named_route_id: return None rows = (await db.execute( select( Activity.id, Activity.name, Activity.start_time, Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate, ) .where( Activity.named_route_id == act.named_route_id, Activity.user_id == current_user.id, Activity.duration_s.isnot(None), ) .order_by(Activity.duration_s) )).all() if not rows: return None fastest_s = rows[0].duration_s entries = [] current = None for i, r in enumerate(rows): entry = { "rank": i + 1, "activity_id": r.id, "name": r.name, "start_time": r.start_time, "duration_s": r.duration_s, "distance_m": r.distance_m, "avg_heart_rate": r.avg_heart_rate, "gap_s": r.duration_s - fastest_s, "is_current": r.id == activity_id, } if entry["is_current"]: current = entry entries.append(entry) return { "route_id": act.named_route_id, "total": len(entries), "fastest_s": fastest_s, "current": current, "top": entries[:10], } @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") # PersonalRecord.activity_id has no cascade, so remove the activity's PR rows # first or the delete fails the FK constraint. (segment_efforts cascade in DB; # data_points/laps cascade via the ORM relationship.) await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id)) 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)) # Drop PR rows referencing this activity (no cascade); the re-parse re-computes them. await db.execute(delete(PersonalRecord).where(PersonalRecord.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"}