562 lines
19 KiB
Python
562 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, aliased
|
|
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")
|
|
|
|
if downsample > 1:
|
|
# Stride in SQL (keep every Nth row by ordered row number) so the DB
|
|
# never ships the rows we'd immediately discard — a long per-second
|
|
# activity is tens of thousands of points and the UI only charts ~1/N.
|
|
rn = func.row_number().over(order_by=ActivityDataPoint.timestamp).label("rn")
|
|
sub = (
|
|
select(ActivityDataPoint, rn)
|
|
.where(ActivityDataPoint.activity_id == activity_id)
|
|
.subquery()
|
|
)
|
|
adp = aliased(ActivityDataPoint, sub)
|
|
q = select(adp).where(sub.c.rn % downsample == 1).order_by(sub.c.timestamp)
|
|
else:
|
|
q = (
|
|
select(ActivityDataPoint)
|
|
.where(ActivityDataPoint.activity_id == activity_id)
|
|
.order_by(ActivityDataPoint.timestamp)
|
|
)
|
|
|
|
result = await db.execute(q)
|
|
return result.scalars().all()
|
|
|
|
|
|
@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"} |