Files
MileVault/backend/app/api/activities.py
T
owain bdd5f80c7e
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s
Harden auth/upload, fix PR-delete cascade and sync backfill
- OIDC: require signed short-lived state on login callback; reject
  missing userinfo sub (account-takeover guard); validate token
  exchange + userinfo responses
- Upload: safe zip extraction (path-traversal + zip-bomb cap),
  streamed size-capped writes, sanitised filenames
- Garmin: increasing lookback resets last_sync_at for one-time backfill
- Activities: delete/reprocess remove PersonalRecord rows (no FK cascade)
- Profile: validate /weight limit; sync lookback UI copy
- Dashboard: sleep shading uses same day as charted body battery

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:24:24 +01:00

311 lines
10 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, 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.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"}