Files
MileVault/backend/app/api/activities.py
T
owain e2870e86a8
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
feat: Activities filters (type/year/date-range/distance) + Summary page with all-time & per-year/per-sport totals and distance-per-year chart
2026-06-21 16:29:33 +01:00

552 lines
19 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, delete
from sqlalchemy.orm import selectinload
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
original_name: Optional[str] = None
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]
named_route_name: Optional[str] = None
active_spans: Optional[list] = None
class Config:
from_attributes = True
class ActivityDetail(ActivitySummary):
end_time: Optional[datetime]
moving_time_s: Optional[float]
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),
}
def _apply_activity_filters(q, *, sport_type, from_date, to_date, year,
min_distance_km, max_distance_km):
"""Apply the shared Activities-list filters to a query selecting Activity."""
from datetime import timezone
if sport_type:
q = q.where(Activity.sport_type == sport_type)
if year:
ys = datetime(year, 1, 1, tzinfo=timezone.utc)
ye = datetime(year + 1, 1, 1, tzinfo=timezone.utc)
q = q.where(Activity.start_time >= ys, Activity.start_time < ye)
if from_date:
q = q.where(Activity.start_time >= from_date)
if to_date:
q = q.where(Activity.start_time <= to_date)
if min_distance_km is not None:
q = q.where(Activity.distance_m >= min_distance_km * 1000)
if max_distance_km is not None:
q = q.where(Activity.distance_m <= max_distance_km * 1000)
return q
@router.get("/stats/filters")
async def filter_options(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Distinct sport types and activity years for this user — drives the
Activities page filter controls."""
from datetime import timezone
yr = func.extract("year", Activity.start_time)
sports = (await db.execute(
select(Activity.sport_type)
.where(Activity.user_id == current_user.id)
.distinct().order_by(Activity.sport_type)
)).scalars().all()
years = (await db.execute(
select(yr.label("yr"))
.where(Activity.user_id == current_user.id)
.distinct().order_by(yr.desc())
)).scalars().all()
return {
"sport_types": [s for s in sports if s],
"years": [int(y) for y in years if y is not None],
}
@router.get("/stats/summary")
async def stats_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Per-year and per-sport totals (count, distance, time, elevation) plus an
all-time roll-up. Powers the Summary page."""
yr = func.extract("year", Activity.start_time)
rows = (await db.execute(
select(
yr.label("yr"),
Activity.sport_type,
func.count().label("cnt"),
func.coalesce(func.sum(Activity.distance_m), 0).label("dist"),
func.coalesce(func.sum(func.coalesce(Activity.moving_time_s, Activity.duration_s)), 0).label("dur"),
func.coalesce(func.sum(Activity.elevation_gain_m), 0).label("elev"),
)
.where(Activity.user_id == current_user.id)
.group_by(yr, Activity.sport_type)
)).all()
years: dict[int, dict] = {}
for r in rows:
if r.yr is None:
continue
yr = int(r.yr)
y = years.setdefault(yr, {"year": yr, "count": 0, "distance_km": 0.0,
"duration_s": 0.0, "elevation_m": 0.0, "by_sport": []})
sport = {
"sport_type": r.sport_type,
"count": int(r.cnt),
"distance_km": round((r.dist or 0) / 1000, 2),
"duration_s": float(r.dur or 0),
"elevation_m": round(r.elev or 0, 1),
}
y["by_sport"].append(sport)
y["count"] += sport["count"]
y["distance_km"] += sport["distance_km"]
y["duration_s"] += sport["duration_s"]
y["elevation_m"] += sport["elevation_m"]
by_year = []
for y in sorted(years.values(), key=lambda x: x["year"], reverse=True):
y["by_sport"].sort(key=lambda s: s["distance_km"], reverse=True)
y["distance_km"] = round(y["distance_km"], 2)
y["elevation_m"] = round(y["elevation_m"], 1)
by_year.append(y)
all_time = {
"count": sum(y["count"] for y in by_year),
"distance_km": round(sum(y["distance_km"] for y in by_year), 2),
"duration_s": sum(y["duration_s"] for y in by_year),
"elevation_m": round(sum(y["elevation_m"] for y in by_year), 1),
}
sport_types = sorted({s["sport_type"] for y in by_year for s in y["by_sport"] if s["sport_type"]})
return {"all_time": all_time, "by_year": by_year, "sport_types": sport_types}
@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,
year: Optional[int] = None,
min_distance_km: Optional[float] = None,
max_distance_km: Optional[float] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
q = _apply_activity_filters(
q, sport_type=sport_type, from_date=from_date, to_date=to_date, year=year,
min_distance_km=min_distance_km, max_distance_km=max_distance_km,
)
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).options(selectinload(Activity.named_route)).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 {}
# This activity's laps, so we know each lap's distance.
this_laps = (await db.execute(
select(ActivityLap.lap_number, ActivityLap.distance_m)
.where(ActivityLap.activity_id == activity_id)
)).all()
this_dist = {ln: d for ln, d in this_laps if d}
# Laps from OTHER activities on the same route, so the comparison excludes
# this activity from its own benchmark.
other_laps = (await db.execute(
select(ActivityLap.lap_number, ActivityLap.distance_m, 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),
ActivityLap.distance_m.isnot(None),
)
)).all()
# Best (fastest) time per lap number, comparing only laps that cover roughly
# the same distance (±5%). Without this, a short/partial lap that happens to
# share a lap number (e.g. a 461 m "lap 5" vs this activity's 1 km lap 5)
# would win as "best" and produce a nonsensical benchmark.
bests: dict[str, float] = {}
for ln, dist, dur in other_laps:
target = this_dist.get(ln)
if target is None or abs(dist - target) > target * 0.05:
continue
key = str(ln)
if key not in bests or dur < bests[key]:
bests[key] = dur
return bests
@router.get("/{activity_id}/records")
async def get_activity_records(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Current personal records that were set in this activity (e.g. a 1 km PR),
so the activity page can flag which records it holds."""
act = (await db.execute(
select(Activity.id).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")
rows = (await db.execute(
select(PersonalRecord)
.where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.activity_id == activity_id,
PersonalRecord.is_current_record == True,
)
.order_by(PersonalRecord.distance_m)
)).scalars().all()
return [
{
"distance_label": r.distance_label,
"distance_m": r.distance_m,
"duration_s": r.duration_s,
}
for r in rows
]
@router.get("/{activity_id}/route-leaderboard")
async def get_route_leaderboard(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest-time leaderboard across all of this user's activities on the same
route. Returns this activity's rank/gap plus the top 10. Null if the activity
has no associated route (or no timed efforts to rank)."""
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 None
rows = (await db.execute(
select(
Activity.id, Activity.name, Activity.start_time,
Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate,
)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
Activity.duration_s.isnot(None),
)
.order_by(Activity.duration_s)
)).all()
if not rows:
return None
fastest_s = rows[0].duration_s
entries = []
current = None
for i, r in enumerate(rows):
entry = {
"rank": i + 1,
"activity_id": r.id,
"name": r.name,
"start_time": r.start_time,
"duration_s": r.duration_s,
"distance_m": r.distance_m,
"avg_heart_rate": r.avg_heart_rate,
"gap_s": r.duration_s - fastest_s,
"is_current": r.id == activity_id,
}
if entry["is_current"]:
current = entry
entries.append(entry)
return {
"route_id": act.named_route_id,
"total": len(entries),
"fastest_s": fastest_s,
"current": current,
"top": entries[:10],
}
@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")
new_name = (body.get("name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
# Preserve the original import title the first time the user renames, so the
# UI can still show it as a tag. Subsequent renames keep that same original.
if activity.original_name is None and new_name != activity.name:
activity.original_name = activity.name
activity.name = new_name
# If the user renames back to the original title, drop the tag.
if activity.original_name == activity.name:
activity.original_name = None
await db.commit()
return {"id": activity_id, "name": activity.name, "original_name": activity.original_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"}