Files
MileVault/backend/app/api/routes.py
T
owain 22b41109f5
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 7s
Build and push images / build-frontend (push) Successful in 10s
Fix Garmin stats sync, add route merge/map/links, fix PR constraint
Garmin health: fix display_name=None when using stored OAuth tokens.
authenticate_garmin() now calls login(tokenstore=...) instead of
garth.loads() directly, so display_name is populated and get_user_summary
works. Also add avg_hr_day / max_hr_day from stats response.

Routes: add merge endpoint (POST /{id}/merge/{source}), delete endpoint.
Routes page: polyline SVG mini-map on each route card, merge UI with
confirmation, activity rows are now Links to the activity detail page.

Personal records: replace all-columns unique constraint with a partial
index (unique on current records only) to stop UniqueViolation crashes
when parallel workers deactivate the same PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 01:25:01 +01:00

288 lines
8.2 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, RouteSegment, Activity
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int
class RouteOut(BaseModel):
id: int
name: str
description: Optional[str]
sport_type: Optional[str]
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
class Config:
from_attributes = True
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
async def create_route(
body: RouteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
route = NamedRoute(
user_id=current_user.id,
name=body.name,
description=body.description,
sport_type=body.sport_type or activity.sport_type,
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}", response_model=RouteOut)
async def get_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
Activity.user_id == current_user.id,
).order_by(Activity.duration_s)
)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"start_time": a.start_time,
"duration_s": a.duration_s,
"distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms,
}
for a in activities
]
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
async def merge_routes(
route_id: int,
source_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move all activities from source route into route_id, then delete source route."""
from sqlalchemy import update
target = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
source = (await db.execute(
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not target or not source:
raise HTTPException(status_code=404, detail="Route not found")
if route_id == source_id:
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
await db.execute(
update(Activity)
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
.values(named_route_id=route_id)
)
await db.delete(source)
await db.commit()
await db.refresh(target)
return target
@router.delete("/{route_id}")
async def delete_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from sqlalchemy import update as sa_update
route = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Unlink activities before deleting
await db.execute(
sa_update(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.values(named_route_id=None)
)
await db.delete(route)
await db.commit()
return {"status": "ok"}
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment