This commit is contained in:
xds
2026-03-16 14:46:20 +03:00
parent 00db55720c
commit de8c2472e2
45 changed files with 3714 additions and 140 deletions

View File

@@ -5,24 +5,34 @@ 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, DataPoint
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(
rider_id: uuid.UUID,
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"):
@@ -37,18 +47,33 @@ async def upload_activity(
content = await file.read()
file_path.write_bytes(content)
activity, data_points = parse_fit_file(content, rider_id, str(file_path))
# 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)
metrics = calculate_metrics(data_points, activity, rider_id, session)
# 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
@@ -56,17 +81,17 @@ async def upload_activity(
@router.get("", response_model=ActivityListResponse)
async def list_activities(
rider_id: uuid.UUID,
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)
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)
.where(Activity.rider_id == rider.id)
.order_by(Activity.date.desc())
.limit(limit)
.offset(offset)
@@ -80,10 +105,11 @@ async def list_activities(
@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:
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@@ -91,8 +117,14 @@ async def get_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)
@@ -100,3 +132,173 @@ async def get_activity_stream(
)
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,
}