import uuid from pathlib import Path from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.auth import get_current_rider from backend.app.core.config import settings from backend.app.core.database import get_session from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval from backend.app.models.fitness import PowerCurve from backend.app.models.rider import Rider from backend.app.schemas.activity import ( ActivityResponse, ActivityListResponse, DataPointResponse, ZonesResponse, PowerCurveResponse, ) from backend.app.models.fitness import DiaryEntry from backend.app.services.fit_parser import parse_fit_file from backend.app.services.metrics import calculate_metrics from backend.app.services.zones import calculate_power_zones, calculate_hr_zones from backend.app.services.power_curve import calculate_power_curve from backend.app.services.intervals import detect_intervals from backend.app.services.ai_summary import generate_summary router = APIRouter() @router.post("/upload", response_model=ActivityResponse) async def upload_activity( file: UploadFile = File(...), rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): if not file.filename or not file.filename.lower().endswith(".fit"): raise HTTPException(status_code=400, detail="Only .FIT files are accepted") upload_dir = Path(settings.UPLOAD_DIR) upload_dir.mkdir(parents=True, exist_ok=True) file_id = uuid.uuid4() file_path = upload_dir / f"{file_id}.fit" content = await file.read() file_path.write_bytes(content) # 1. Parse FIT activity, data_points = parse_fit_file(content, rider.id, str(file_path)) session.add(activity) await session.flush() # 2. Save data points for dp in data_points: dp.activity_id = activity.id session.add_all(data_points) # 3. Calculate & save metrics (with FTP if available) metrics = calculate_metrics(data_points, activity, ftp=rider.ftp) if metrics: session.add(metrics) # 4. Detect & save intervals intervals = detect_intervals(data_points, ftp=rider.ftp) for interval in intervals: interval.activity_id = activity.id session.add_all(intervals) # 5. Calculate & save power curve curve_data = calculate_power_curve(data_points) if curve_data: pc = PowerCurve(activity_id=activity.id, curve_data=curve_data) session.add(pc) await session.commit() await session.refresh(activity) return activity @router.get("", response_model=ActivityListResponse) async def list_activities( limit: int = 20, offset: int = 0, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider.id) total = (await session.execute(count_query)).scalar() or 0 query = ( select(Activity) .where(Activity.rider_id == rider.id) .order_by(Activity.date.desc()) .limit(limit) .offset(offset) ) result = await session.execute(query) activities = result.scalars().all() return ActivityListResponse(items=activities, total=total) @router.get("/{activity_id}", response_model=ActivityResponse) async def get_activity( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") return activity @router.get("/{activity_id}/stream", response_model=list[DataPointResponse]) async def get_activity_stream( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): # Verify ownership activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") query = ( select(DataPoint) .where(DataPoint.activity_id == activity_id) .order_by(DataPoint.timestamp) ) result = await session.execute(query) return result.scalars().all() @router.get("/{activity_id}/zones", response_model=ZonesResponse) async def get_activity_zones( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") query = ( select(DataPoint) .where(DataPoint.activity_id == activity_id) .order_by(DataPoint.timestamp) ) result = await session.execute(query) data_points = list(result.scalars().all()) power_zones = [] hr_zones = [] if rider.ftp: power_zones = calculate_power_zones(data_points, rider.ftp) if rider.lthr: hr_zones = calculate_hr_zones(data_points, rider.lthr) return ZonesResponse(power_zones=power_zones, hr_zones=hr_zones) @router.get("/{activity_id}/power-curve", response_model=PowerCurveResponse) async def get_activity_power_curve( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") # Try cached query = select(PowerCurve).where(PowerCurve.activity_id == activity_id) result = await session.execute(query) pc = result.scalar_one_or_none() if pc: return PowerCurveResponse(curve=pc.curve_data) # Calculate on the fly dp_query = ( select(DataPoint) .where(DataPoint.activity_id == activity_id) .order_by(DataPoint.timestamp) ) dp_result = await session.execute(dp_query) data_points = list(dp_result.scalars().all()) curve = calculate_power_curve(data_points) return PowerCurveResponse(curve=curve) @router.post("/{activity_id}/ai-summary") async def generate_ai_summary( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") # Check for existing diary entry with summary query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) result = await session.execute(query) diary = result.scalar_one_or_none() if diary and diary.ai_summary: return {"summary": diary.ai_summary} # Generate new summary summary = await generate_summary(activity, rider_ftp=rider.ftp) if diary: diary.ai_summary = summary else: diary = DiaryEntry(activity_id=activity_id, ai_summary=summary) session.add(diary) await session.commit() return {"summary": summary} @router.delete("/{activity_id}") async def delete_activity( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") # Delete related records for model in [DataPoint, Interval, ActivityMetrics, PowerCurve, DiaryEntry]: q = select(model).where(model.activity_id == activity_id) result = await session.execute(q) for row in result.scalars().all(): await session.delete(row) await session.delete(activity) await session.commit() return {"ok": True} @router.get("/{activity_id}/diary") async def get_diary( activity_id: uuid.UUID, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) result = await session.execute(query) diary = result.scalar_one_or_none() if not diary: return {"rider_notes": None, "mood": None, "rpe": None, "sleep_hours": None} return { "rider_notes": diary.rider_notes, "mood": diary.mood, "rpe": diary.rpe, "sleep_hours": diary.sleep_hours, } @router.put("/{activity_id}/diary") async def update_diary( activity_id: uuid.UUID, data: dict, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) result = await session.execute(query) diary = result.scalar_one_or_none() if not diary: diary = DiaryEntry(activity_id=activity_id) session.add(diary) for field in ["rider_notes", "mood", "rpe", "sleep_hours"]: if field in data: setattr(diary, field, data[field]) await session.commit() return { "rider_notes": diary.rider_notes, "mood": diary.mood, "rpe": diary.rpe, "sleep_hours": diary.sleep_hours, }