from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, func 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 activity_count: int = 0 class Config: from_attributes = True class SegmentOut(BaseModel): id: int name: str start_distance_m: float end_distance_m: float description: Optional[str] auto_generated: Optional[bool] = False auto_generated_type: Optional[str] = None class Config: from_attributes = True class AutoGenerateRequest(BaseModel): type: str # "1km" | "turns" | "hills" gradient_pct: float = 5.0 turn_angle_deg: float = 45.0 class SegmentTimeEntry(BaseModel): activity_id: int date: datetime name: str duration_s: float @router.get("/", response_model=List[RouteOut]) async def list_routes( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): # Fetch routes with activity counts in one query count_subq = ( select(Activity.named_route_id, func.count(Activity.id).label("cnt")) .where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None)) .group_by(Activity.named_route_id) .subquery() ) result = await db.execute( select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count")) .outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id) .where(NamedRoute.user_id == current_user.id) .order_by(desc(NamedRoute.created_at)) ) rows = result.all() out = [] for route, cnt in rows: d = {c.name: getattr(route, c.name) for c in route.__table__.columns} d["activity_count"] = cnt out.append(RouteOut(**d)) return out @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"} async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute: result = await db.execute( select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == 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}/segments", response_model=List[SegmentOut]) async def list_segments( route_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _get_owned_route(route_id, current_user.id, db) 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), ): await _get_owned_route(route_id, current_user.id, db) 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, auto_generated=False, ) db.add(segment) await db.commit() await db.refresh(segment) return segment @router.delete("/{route_id}/segments/{segment_id}", status_code=204) async def delete_segment( route_id: int, segment_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): await _get_owned_route(route_id, current_user.id, db) result = await db.execute( select(RouteSegment).where( RouteSegment.id == segment_id, RouteSegment.route_id == route_id ) ) seg = result.scalar_one_or_none() if not seg: raise HTTPException(status_code=404, detail="Segment not found") await db.delete(seg) await db.commit() @router.post("/{route_id}/segments/auto", response_model=List[SegmentOut]) async def auto_generate_segments( route_id: int, body: AutoGenerateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Auto-generate segments: 1km splits, turns, or hills.""" from app.services.route_matcher import ( generate_1km_segments, generate_turn_segments, generate_hill_segments, ) from sqlalchemy import delete as sql_delete route = await _get_owned_route(route_id, current_user.id, db) if body.type not in ("1km", "turns", "hills"): raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'") # Clear only auto-generated segments of the same type so other auto types are preserved await db.execute( sql_delete(RouteSegment).where( RouteSegment.route_id == route_id, RouteSegment.auto_generated == True, RouteSegment.auto_generated_type == body.type, ) ) raw_segments: list[tuple[str, float, float]] = [] if body.type == "1km": if not route.distance_m: raise HTTPException(status_code=400, detail="Route has no distance recorded") raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m) elif body.type == "turns": if not route.reference_polyline: raise HTTPException(status_code=400, detail="Route has no polyline") raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg) elif body.type == "hills": if not route.reference_polyline: raise HTTPException(status_code=400, detail="Route has no polyline") # Find most recent matched activity for elevation data act_result = await db.execute( select(Activity) .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) .order_by(desc(Activity.start_time)) .limit(1) ) act = act_result.scalar_one_or_none() if not act: raise HTTPException(status_code=400, detail="No matched activities found for elevation data") from app.models.user import ActivityDataPoint dp_result = await db.execute( select(ActivityDataPoint) .where(ActivityDataPoint.activity_id == act.id) .order_by(ActivityDataPoint.timestamp) ) dps = dp_result.scalars().all() dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps] raw_segments = generate_hill_segments(dp_list, body.gradient_pct) new_segments = [] for name, start_m, end_m in raw_segments: seg = RouteSegment( route_id=route_id, name=name, start_distance_m=start_m, end_distance_m=end_m, auto_generated=True, auto_generated_type=body.type, ) db.add(seg) new_segments.append(seg) await db.commit() for seg in new_segments: await db.refresh(seg) return new_segments class SegmentBestOut(BaseModel): segment_id: int name: str start_distance_m: float end_distance_m: float auto_generated: bool best_s: Optional[float] best_activity_id: Optional[int] count: int @router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut]) async def get_segment_bests( route_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return best time per segment across all matched activities for a route.""" from app.services.route_matcher import find_segment_times from app.models.user import ActivityDataPoint from collections import defaultdict await _get_owned_route(route_id, current_user.id, db) segs_result = await db.execute( select(RouteSegment) .where(RouteSegment.route_id == route_id) .order_by(RouteSegment.start_distance_m) ) segments = segs_result.scalars().all() if not segments: return [] acts_result = await db.execute( select(Activity) .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) .order_by(desc(Activity.start_time)) .limit(20) ) activities = acts_result.scalars().all() if not activities: return [ SegmentBestOut( segment_id=s.id, name=s.name, start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m, auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0, ) for s in segments ] act_ids = [a.id for a in activities] dp_result = await db.execute( select(ActivityDataPoint) .where(ActivityDataPoint.activity_id.in_(act_ids)) .order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp) ) all_dps = dp_result.scalars().all() # Group data points by activity_id dp_by_act = defaultdict(list) for dp in all_dps: if dp.distance_m is not None: dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp}) bests = [] for seg in segments: best_s = None best_act_id = None count = 0 for act_id in act_ids: dp_list = dp_by_act.get(act_id, []) duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m) if duration is not None: count += 1 if best_s is None or duration < best_s: best_s = duration best_act_id = act_id bests.append(SegmentBestOut( segment_id=seg.id, name=seg.name, start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m, auto_generated=bool(seg.auto_generated), best_s=best_s, best_activity_id=best_act_id, count=count, )) return bests @router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry]) async def get_segment_times( route_id: int, segment_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return the last 10 times this segment was traversed across matched activities.""" from app.services.route_matcher import find_segment_times from app.models.user import ActivityDataPoint await _get_owned_route(route_id, current_user.id, db) seg_result = await db.execute( select(RouteSegment).where( RouteSegment.id == segment_id, RouteSegment.route_id == route_id ) ) seg = seg_result.scalar_one_or_none() if not seg: raise HTTPException(status_code=404, detail="Segment not found") acts_result = await db.execute( select(Activity) .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) .order_by(desc(Activity.start_time)) .limit(10) ) activities = acts_result.scalars().all() times = [] for act in activities: dp_result = await db.execute( select(ActivityDataPoint) .where(ActivityDataPoint.activity_id == act.id) .order_by(ActivityDataPoint.timestamp) ) dps = dp_result.scalars().all() dp_list = [ {"distance_m": p.distance_m, "timestamp": p.timestamp} for p in dps if p.distance_m is not None ] duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m) if duration: times.append(SegmentTimeEntry( activity_id=act.id, date=act.start_time, name=act.name, duration_s=duration, )) return times