Files
MileVault/backend/app/api/activities.py
T
owain e9811d8d83
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
Fix duplicate detection, add wellness suffixes, add reprocess endpoint
2026-06-06 19:02:42 +01:00

247 lines
7.3 KiB
Python

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