Initial Commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
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
|
||||
|
||||
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("/", 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),
|
||||
):
|
||||
# Verify ownership
|
||||
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.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")
|
||||
await db.delete(activity)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,134 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import httpx
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, create_access_token, hash_password, get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user_id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str]
|
||||
is_admin: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == form_data.username)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.hashed_password:
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
if not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Invalid credentials")
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return Token(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/pocketid/available")
|
||||
async def pocketid_available():
|
||||
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)}
|
||||
|
||||
|
||||
@router.get("/pocketid/login-url")
|
||||
async def pocketid_login_url():
|
||||
"""Return the OIDC authorization URL for PocketID."""
|
||||
if not settings.pocketid_issuer:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
params = {
|
||||
"client_id": settings.pocketid_client_id,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.get("/pocketid/callback")
|
||||
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Exchange OIDC code for tokens and create/login user."""
|
||||
if not settings.pocketid_issuer:
|
||||
raise HTTPException(status_code=404, detail="PocketID not configured")
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{settings.pocketid_issuer}/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": "/api/auth/pocketid/callback",
|
||||
"client_id": settings.pocketid_client_id,
|
||||
"client_secret": settings.pocketid_client_secret,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Token exchange failed")
|
||||
|
||||
tokens = resp.json()
|
||||
userinfo_resp = await client.get(
|
||||
f"{settings.pocketid_issuer}/userinfo",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
userinfo = userinfo_resp.json()
|
||||
|
||||
sub = userinfo.get("sub")
|
||||
email = userinfo.get("email")
|
||||
preferred_username = userinfo.get("preferred_username") or email
|
||||
|
||||
result = await db.execute(select(User).where(User.pocketid_sub == sub))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
username=preferred_username,
|
||||
email=email,
|
||||
pocketid_sub=sub,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
# Redirect to frontend with token
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"/?token={token}")
|
||||
@@ -0,0 +1,156 @@
|
||||
from fastapi import APIRouter, Depends, 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, date
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, HealthMetric
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HealthMetricOut(BaseModel):
|
||||
id: int
|
||||
date: datetime
|
||||
resting_hr: Optional[float]
|
||||
max_hr_day: Optional[float]
|
||||
avg_hr_day: Optional[float]
|
||||
hrv_nightly_avg: Optional[float]
|
||||
hrv_status: Optional[str]
|
||||
hrv_5min_high: Optional[float]
|
||||
hrv_5min_low: Optional[float]
|
||||
sleep_duration_s: Optional[float]
|
||||
sleep_deep_s: Optional[float]
|
||||
sleep_light_s: Optional[float]
|
||||
sleep_rem_s: Optional[float]
|
||||
sleep_awake_s: Optional[float]
|
||||
sleep_score: Optional[float]
|
||||
sleep_start: Optional[datetime]
|
||||
sleep_end: Optional[datetime]
|
||||
weight_kg: Optional[float]
|
||||
bmi: Optional[float]
|
||||
body_fat_pct: Optional[float]
|
||||
muscle_mass_kg: Optional[float]
|
||||
vo2max: Optional[float]
|
||||
fitness_age: Optional[int]
|
||||
training_load: Optional[float]
|
||||
recovery_time_h: Optional[float]
|
||||
avg_stress: Optional[float]
|
||||
steps: Optional[int]
|
||||
floors_climbed: Optional[int]
|
||||
active_calories: Optional[float]
|
||||
total_calories: Optional[float]
|
||||
spo2_avg: Optional[float]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[HealthMetricOut])
|
||||
async def list_health_metrics(
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
limit: int = Query(365, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
|
||||
if from_date:
|
||||
q = q.where(HealthMetric.date >= from_date)
|
||||
if to_date:
|
||||
q = q.where(HealthMetric.date <= to_date)
|
||||
q = q.order_by(desc(HealthMetric.date)).limit(limit)
|
||||
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def health_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Latest values + 30-day averages for dashboard widgets."""
|
||||
# Latest record
|
||||
latest_result = await db.execute(
|
||||
select(HealthMetric)
|
||||
.where(HealthMetric.user_id == current_user.id)
|
||||
.order_by(desc(HealthMetric.date))
|
||||
.limit(1)
|
||||
)
|
||||
latest = latest_result.scalar_one_or_none()
|
||||
|
||||
# 30-day averages
|
||||
from datetime import timedelta, timezone
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||
avg_result = await db.execute(
|
||||
select(
|
||||
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
|
||||
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
|
||||
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
|
||||
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
|
||||
func.avg(HealthMetric.avg_stress).label("avg_stress"),
|
||||
func.avg(HealthMetric.steps).label("avg_steps"),
|
||||
func.avg(HealthMetric.weight_kg).label("avg_weight"),
|
||||
).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
HealthMetric.date >= cutoff,
|
||||
)
|
||||
)
|
||||
avgs = avg_result.one()
|
||||
|
||||
return {
|
||||
"latest": HealthMetricOut.model_validate(latest) if latest else None,
|
||||
"avg_30d": {
|
||||
"resting_hr": avgs.avg_resting_hr,
|
||||
"hrv": avgs.avg_hrv,
|
||||
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
|
||||
"sleep_score": avgs.avg_sleep_score,
|
||||
"stress": avgs.avg_stress,
|
||||
"steps": avgs.avg_steps,
|
||||
"weight_kg": avgs.avg_weight,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/manual")
|
||||
async def add_manual_metric(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually add or update a health metric for a given date."""
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
date_str = body.get("date")
|
||||
if not date_str:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="date required")
|
||||
|
||||
metric_date = datetime.fromisoformat(date_str)
|
||||
|
||||
# Check for existing
|
||||
existing = await db.execute(
|
||||
select(HealthMetric).where(
|
||||
HealthMetric.user_id == current_user.id,
|
||||
func.date(HealthMetric.date) == metric_date.date(),
|
||||
)
|
||||
)
|
||||
metric = existing.scalar_one_or_none()
|
||||
|
||||
if metric:
|
||||
for key, val in body.items():
|
||||
if hasattr(metric, key) and key not in ("id", "user_id"):
|
||||
setattr(metric, key, val)
|
||||
else:
|
||||
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
|
||||
k: v for k, v in body.items()
|
||||
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
|
||||
})
|
||||
db.add(metric)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Personal Records ────────────────────────────────────────────────────────
|
||||
|
||||
class PROut(BaseModel):
|
||||
id: int
|
||||
sport_type: str
|
||||
distance_m: float
|
||||
distance_label: str
|
||||
duration_s: float
|
||||
achieved_at: datetime
|
||||
activity_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PROut])
|
||||
async def list_records(
|
||||
sport_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = select(PersonalRecord).where(
|
||||
PersonalRecord.user_id == current_user.id,
|
||||
PersonalRecord.is_current_record == True,
|
||||
)
|
||||
if sport_type:
|
||||
q = q.where(PersonalRecord.sport_type == sport_type)
|
||||
q = q.order_by(PersonalRecord.distance_m)
|
||||
result = await db.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/history/{distance_label}")
|
||||
async def record_history(
|
||||
distance_label: str,
|
||||
sport_type: str = "running",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Show progression of a PR over time."""
|
||||
result = await db.execute(
|
||||
select(PersonalRecord).where(
|
||||
PersonalRecord.user_id == current_user.id,
|
||||
PersonalRecord.sport_type == sport_type,
|
||||
PersonalRecord.distance_label == distance_label,
|
||||
).order_by(PersonalRecord.achieved_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,204 @@
|
||||
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
|
||||
|
||||
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 # use this activity as the reference route
|
||||
|
||||
|
||||
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]
|
||||
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.post("/", response_model=RouteOut)
|
||||
async def create_route(
|
||||
body: RouteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Load the reference activity
|
||||
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,
|
||||
)
|
||||
db.add(route)
|
||||
await db.flush()
|
||||
|
||||
# Link this activity to the route
|
||||
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),
|
||||
):
|
||||
"""All activities on this named route, ordered fastest first."""
|
||||
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}/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),
|
||||
):
|
||||
"""Manually assign an activity to a named route."""
|
||||
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
|
||||
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.workers.tasks import process_activity_file, process_garmin_health_zip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
|
||||
|
||||
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / upload.filename
|
||||
with open(dest, "wb") as f:
|
||||
shutil.copyfileobj(upload.file, f)
|
||||
return dest
|
||||
|
||||
|
||||
@router.post("/activity")
|
||||
async def upload_activity(
|
||||
file: UploadFile = File(...),
|
||||
background_tasks: BackgroundTasks = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a single .fit or .gpx activity file."""
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".fit", ".gpx"}:
|
||||
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
# Queue processing
|
||||
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
|
||||
|
||||
return {"task_id": task.id, "status": "queued", "filename": file.filename}
|
||||
|
||||
|
||||
@router.post("/garmin-export")
|
||||
async def upload_garmin_export(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Upload a full Garmin Connect data export ZIP.
|
||||
Processes all FIT files for activities + wellness data.
|
||||
"""
|
||||
if not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
# Extract and queue all FIT files
|
||||
extract_dir = dest_dir / f"garmin_{dest.stem}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit"):
|
||||
fit_path = extract_dir / name
|
||||
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
|
||||
task_ids.append(task.id)
|
||||
|
||||
# Queue health/wellness data extraction
|
||||
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
"health_task": health_task.id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/strava-export")
|
||||
async def upload_strava_export(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
|
||||
if not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
|
||||
|
||||
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
|
||||
dest = save_upload(file, dest_dir)
|
||||
|
||||
extract_dir = dest_dir / f"strava_{dest.stem}"
|
||||
extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
task_ids = []
|
||||
with zipfile.ZipFile(dest) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
for name in zf.namelist():
|
||||
lower = name.lower()
|
||||
if lower.endswith(".fit") or lower.endswith(".gpx"):
|
||||
file_path = extract_dir / name
|
||||
ext = Path(name).suffix[1:]
|
||||
task = process_activity_file.delay(str(file_path), current_user.id, ext)
|
||||
task_ids.append(task.id)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"activity_tasks": len(task_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/task/{task_id}")
|
||||
async def check_task_status(
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Check the status of an upload processing task."""
|
||||
from app.workers.celery_app import celery_app
|
||||
result = celery_app.AsyncResult(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": result.status,
|
||||
"result": result.result if result.ready() else None,
|
||||
}
|
||||
Reference in New Issue
Block a user