Files
sport-platform/backend/app/api/activities.py
2026-03-16 14:46:20 +03:00

305 lines
9.7 KiB
Python

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,
}