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,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = Field(..., env="DATABASE_URL")
|
||||
|
||||
# Redis
|
||||
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
|
||||
|
||||
# Auth
|
||||
secret_key: str = Field(..., env="SECRET_KEY")
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Admin account
|
||||
admin_username: str = Field("admin", env="ADMIN_USERNAME")
|
||||
admin_password: str = Field(..., env="ADMIN_PASSWORD")
|
||||
|
||||
# PocketID OIDC (optional)
|
||||
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
|
||||
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
|
||||
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
|
||||
|
||||
# Files
|
||||
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
|
||||
|
||||
# Environment
|
||||
environment: str = Field("production", env="ENVIRONMENT")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.environment == "development",
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||
)
|
||||
to_encode["exp"] = expire
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1,71 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.database import engine, AsyncSessionLocal, Base
|
||||
from app.core.config import settings
|
||||
from app.api import auth, activities, routes, health, records, upload
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Try to enable TimescaleDB hypertable for data points
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"SELECT create_hypertable('activity_data_points', 'timestamp', "
|
||||
"if_not_exists => TRUE, migrate_data => TRUE)"
|
||||
))
|
||||
except Exception:
|
||||
pass # Already exists or TimescaleDB not available
|
||||
|
||||
# Seed admin user
|
||||
async with AsyncSessionLocal() as db:
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == settings.admin_username)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
hashed_password=hash_password(settings.admin_password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FitTracker",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if settings.environment == "development" else [],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
|
||||
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
|
||||
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
|
||||
app.include_router(records.router, prefix="/api/records", tags=["records"])
|
||||
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def healthcheck():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,233 @@
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Float, DateTime, Boolean,
|
||||
ForeignKey, Text, JSON, Index, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
def now_utc():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||
email = Column(String(256), unique=True, nullable=True)
|
||||
hashed_password = Column(String(256), nullable=True) # null = OIDC-only user
|
||||
is_admin = Column(Boolean, default=False)
|
||||
pocketid_sub = Column(String(256), unique=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
|
||||
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
|
||||
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
__tablename__ = "activities"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Core fields
|
||||
name = Column(String(256), nullable=False)
|
||||
sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc.
|
||||
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Metrics summary (cached aggregates)
|
||||
distance_m = Column(Float, nullable=True) # metres
|
||||
duration_s = Column(Float, nullable=True) # seconds
|
||||
elevation_gain_m = Column(Float, nullable=True)
|
||||
elevation_loss_m = Column(Float, nullable=True)
|
||||
avg_heart_rate = Column(Float, nullable=True)
|
||||
max_heart_rate = Column(Float, nullable=True)
|
||||
avg_cadence = Column(Float, nullable=True)
|
||||
avg_power = Column(Float, nullable=True)
|
||||
normalized_power = Column(Float, nullable=True)
|
||||
avg_speed_ms = Column(Float, nullable=True)
|
||||
max_speed_ms = Column(Float, nullable=True)
|
||||
avg_temperature_c = Column(Float, nullable=True)
|
||||
calories = Column(Float, nullable=True)
|
||||
training_stress_score = Column(Float, nullable=True)
|
||||
vo2max_estimate = Column(Float, nullable=True)
|
||||
|
||||
# Route reference
|
||||
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
|
||||
|
||||
# Raw GPS track (encoded polyline for quick map render)
|
||||
polyline = Column(Text, nullable=True)
|
||||
bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon}
|
||||
|
||||
# Source file info
|
||||
source_file = Column(String(512), nullable=True)
|
||||
source_type = Column(String(32), nullable=True) # fit, gpx, strava_json
|
||||
garmin_activity_id = Column(String(64), nullable=True, unique=True)
|
||||
strava_activity_id = Column(String(64), nullable=True, unique=True)
|
||||
|
||||
# HR zones (% of time in each zone)
|
||||
hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...}
|
||||
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="activities")
|
||||
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
|
||||
named_route = relationship("NamedRoute", back_populates="activities")
|
||||
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ActivityDataPoint(Base):
|
||||
"""
|
||||
TimescaleDB hypertable - one row per second of activity data.
|
||||
After creation, converted to hypertable in migration:
|
||||
SELECT create_hypertable('activity_data_points', 'timestamp');
|
||||
"""
|
||||
__tablename__ = "activity_data_points"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
altitude_m = Column(Float, nullable=True)
|
||||
heart_rate = Column(Float, nullable=True)
|
||||
cadence = Column(Float, nullable=True)
|
||||
speed_ms = Column(Float, nullable=True)
|
||||
power = Column(Float, nullable=True)
|
||||
temperature_c = Column(Float, nullable=True)
|
||||
distance_m = Column(Float, nullable=True) # cumulative distance
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_adp_activity_time", "activity_id", "timestamp"),
|
||||
)
|
||||
|
||||
activity = relationship("Activity", back_populates="data_points")
|
||||
|
||||
|
||||
class ActivityLap(Base):
|
||||
__tablename__ = "activity_laps"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
|
||||
lap_number = Column(Integer, nullable=False)
|
||||
start_time = Column(DateTime(timezone=True), nullable=True)
|
||||
duration_s = Column(Float, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
avg_heart_rate = Column(Float, nullable=True)
|
||||
avg_cadence = Column(Float, nullable=True)
|
||||
avg_speed_ms = Column(Float, nullable=True)
|
||||
avg_power = Column(Float, nullable=True)
|
||||
|
||||
activity = relationship("Activity", back_populates="laps")
|
||||
|
||||
|
||||
class NamedRoute(Base):
|
||||
__tablename__ = "named_routes"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
sport_type = Column(String(64), nullable=True)
|
||||
reference_polyline = Column(Text, nullable=True) # canonical route polyline
|
||||
bounding_box = Column(JSON, nullable=True)
|
||||
distance_m = Column(Float, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
user = relationship("User", back_populates="named_routes")
|
||||
activities = relationship("Activity", back_populates="named_route")
|
||||
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RouteSegment(Base):
|
||||
"""Named sections within a route for targeted comparisons (e.g. 'The big hill')"""
|
||||
__tablename__ = "route_segments"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
|
||||
name = Column(String(256), nullable=False)
|
||||
start_distance_m = Column(Float, nullable=False) # distance into route where segment starts
|
||||
end_distance_m = Column(Float, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
route = relationship("NamedRoute", back_populates="segments")
|
||||
|
||||
|
||||
class PersonalRecord(Base):
|
||||
__tablename__ = "personal_records"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
|
||||
sport_type = Column(String(64), nullable=False)
|
||||
distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195
|
||||
distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k"
|
||||
duration_s = Column(Float, nullable=False)
|
||||
achieved_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_current_record = Column(Boolean, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
|
||||
name="uq_pr_current"),
|
||||
)
|
||||
|
||||
|
||||
class HealthMetric(Base):
|
||||
"""Daily health summary metrics from Garmin Connect / FIT wellness data"""
|
||||
__tablename__ = "health_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
date = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Heart rate
|
||||
resting_hr = Column(Float, nullable=True)
|
||||
max_hr_day = Column(Float, nullable=True)
|
||||
avg_hr_day = Column(Float, nullable=True)
|
||||
|
||||
# HRV
|
||||
hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc.
|
||||
hrv_nightly_avg = Column(Float, nullable=True)
|
||||
hrv_5min_high = Column(Float, nullable=True)
|
||||
hrv_5min_low = Column(Float, nullable=True)
|
||||
|
||||
# Sleep
|
||||
sleep_duration_s = Column(Float, nullable=True)
|
||||
sleep_deep_s = Column(Float, nullable=True)
|
||||
sleep_light_s = Column(Float, nullable=True)
|
||||
sleep_rem_s = Column(Float, nullable=True)
|
||||
sleep_awake_s = Column(Float, nullable=True)
|
||||
sleep_score = Column(Float, nullable=True)
|
||||
sleep_start = Column(DateTime(timezone=True), nullable=True)
|
||||
sleep_end = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Body composition
|
||||
weight_kg = Column(Float, nullable=True)
|
||||
bmi = Column(Float, nullable=True)
|
||||
body_fat_pct = Column(Float, nullable=True)
|
||||
muscle_mass_kg = Column(Float, nullable=True)
|
||||
|
||||
# Fitness
|
||||
vo2max = Column(Float, nullable=True)
|
||||
fitness_age = Column(Integer, nullable=True)
|
||||
training_load = Column(Float, nullable=True)
|
||||
recovery_time_h = Column(Float, nullable=True)
|
||||
|
||||
# Stress & activity
|
||||
avg_stress = Column(Float, nullable=True)
|
||||
steps = Column(Integer, nullable=True)
|
||||
floors_climbed = Column(Integer, nullable=True)
|
||||
active_calories = Column(Float, nullable=True)
|
||||
total_calories = Column(Float, nullable=True)
|
||||
spo2_avg = Column(Float, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||
Index("ix_health_user_date", "user_id", "date"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="health_metrics")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Parses Garmin .fit files and GPX files into normalized activity data.
|
||||
Handles full Strava and Garmin data export archives.
|
||||
"""
|
||||
import os
|
||||
import zipfile
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import fitparse
|
||||
import gpxpy
|
||||
import polyline as polyline_lib
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
|
||||
"""Returns distance in metres between two GPS points."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def semicircles_to_degrees(sc: int) -> float:
|
||||
return sc * (180 / 2**31)
|
||||
|
||||
|
||||
def parse_fit_file(filepath: str) -> dict:
|
||||
"""Parse a Garmin .fit file and return normalized activity dict."""
|
||||
fit = fitparse.FitFile(filepath)
|
||||
|
||||
data_points = []
|
||||
laps = []
|
||||
session = {}
|
||||
|
||||
for record in fit.get_messages():
|
||||
name = record.name
|
||||
|
||||
if name == "session":
|
||||
for f in record:
|
||||
session[f.name] = f.value
|
||||
|
||||
elif name == "lap":
|
||||
lap = {}
|
||||
for f in record:
|
||||
lap[f.name] = f.value
|
||||
laps.append(lap)
|
||||
|
||||
elif name == "record":
|
||||
point = {}
|
||||
for f in record:
|
||||
point[f.name] = f.value
|
||||
if point:
|
||||
# Convert semicircles to degrees
|
||||
if "position_lat" in point and point["position_lat"] is not None:
|
||||
point["position_lat"] = semicircles_to_degrees(point["position_lat"])
|
||||
if "position_long" in point and point["position_long"] is not None:
|
||||
point["position_long"] = semicircles_to_degrees(point["position_long"])
|
||||
data_points.append(point)
|
||||
|
||||
# Build normalized output
|
||||
sport = str(session.get("sport", "generic")).lower()
|
||||
sport_map = {
|
||||
"running": "running", "cycling": "cycling", "swimming": "swimming",
|
||||
"hiking": "hiking", "walking": "walking", "generic": "other",
|
||||
"open_water_swimming": "swimming", "trail_running": "running",
|
||||
}
|
||||
sport_type = sport_map.get(sport, sport)
|
||||
|
||||
start_time = session.get("start_time")
|
||||
if start_time and start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Build GPS track for polyline
|
||||
coords = [
|
||||
(p["position_lat"], p["position_long"])
|
||||
for p in data_points
|
||||
if p.get("position_lat") is not None and p.get("position_long") is not None
|
||||
]
|
||||
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Calculate cumulative distance if not in FIT
|
||||
cumulative_dist = 0.0
|
||||
prev_lat, prev_lon = None, None
|
||||
normalized_points = []
|
||||
for p in data_points:
|
||||
ts = p.get("timestamp")
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
lat = p.get("position_lat")
|
||||
lon = p.get("position_long")
|
||||
|
||||
dist = p.get("distance")
|
||||
if dist is None and lat and lon and prev_lat and prev_lon:
|
||||
cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon)
|
||||
dist = cumulative_dist
|
||||
elif dist is not None:
|
||||
cumulative_dist = float(dist)
|
||||
|
||||
if lat and lon:
|
||||
prev_lat, prev_lon = lat, lon
|
||||
|
||||
normalized_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"altitude_m": p.get("altitude"),
|
||||
"heart_rate": p.get("heart_rate"),
|
||||
"cadence": p.get("cadence"),
|
||||
"speed_ms": p.get("speed"),
|
||||
"power": p.get("power"),
|
||||
"temperature_c": p.get("temperature"),
|
||||
"distance_m": dist,
|
||||
})
|
||||
|
||||
# Parse laps
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = lap.get("start_time")
|
||||
if ls and ls.tzinfo is None:
|
||||
ls = ls.replace(tzinfo=timezone.utc)
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": _safe_float(lap.get("total_elapsed_time")),
|
||||
"distance_m": _safe_float(lap.get("total_distance")),
|
||||
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(lap.get("avg_cadence")),
|
||||
"avg_speed_ms": _safe_float(lap.get("avg_speed")),
|
||||
"avg_power": _safe_float(lap.get("avg_power")),
|
||||
})
|
||||
|
||||
return {
|
||||
"name": session.get("sport", "Activity").title() + " " + (
|
||||
start_time.strftime("%Y-%m-%d") if start_time else ""),
|
||||
"sport_type": sport_type,
|
||||
"start_time": start_time.isoformat() if start_time else None,
|
||||
"distance_m": _safe_float(session.get("total_distance")),
|
||||
"duration_s": _safe_float(session.get("total_elapsed_time")),
|
||||
"elevation_gain_m": _safe_float(session.get("total_ascent")),
|
||||
"elevation_loss_m": _safe_float(session.get("total_descent")),
|
||||
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
|
||||
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
|
||||
"avg_cadence": _safe_float(session.get("avg_cadence")),
|
||||
"avg_power": _safe_float(session.get("avg_power")),
|
||||
"normalized_power": _safe_float(session.get("normalized_power")),
|
||||
"avg_speed_ms": _safe_float(session.get("avg_speed")),
|
||||
"max_speed_ms": _safe_float(session.get("max_speed")),
|
||||
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
|
||||
"calories": _safe_float(session.get("total_calories")),
|
||||
"training_stress_score": _safe_float(session.get("training_stress_score")),
|
||||
"vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "fit",
|
||||
"data_points": normalized_points,
|
||||
"laps": normalized_laps,
|
||||
}
|
||||
|
||||
|
||||
def parse_gpx_file(filepath: str) -> dict:
|
||||
"""Parse a GPX file into normalized activity dict."""
|
||||
with open(filepath) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
data_points = []
|
||||
track = gpx.tracks[0] if gpx.tracks else None
|
||||
|
||||
if not track:
|
||||
raise ValueError("No tracks found in GPX file")
|
||||
|
||||
for segment in track.segments:
|
||||
for pt in segment.points:
|
||||
ts = pt.time
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
extensions = {}
|
||||
if pt.extensions:
|
||||
for ext in pt.extensions:
|
||||
for child in ext:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
try:
|
||||
extensions[tag] = float(child.text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
data_points.append({
|
||||
"timestamp": ts.isoformat() if ts else None,
|
||||
"latitude": pt.latitude,
|
||||
"longitude": pt.longitude,
|
||||
"altitude_m": pt.elevation,
|
||||
"heart_rate": extensions.get("hr"),
|
||||
"cadence": extensions.get("cad"),
|
||||
"speed_ms": extensions.get("speed"),
|
||||
"power": extensions.get("power"),
|
||||
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
|
||||
"distance_m": None,
|
||||
})
|
||||
|
||||
# Calculate distance and elevation
|
||||
coords = [(p["latitude"], p["longitude"]) for p in data_points
|
||||
if p["latitude"] and p["longitude"]]
|
||||
encoded_polyline = polyline_lib.encode(coords) if coords else None
|
||||
bounding_box = _bounding_box(coords)
|
||||
|
||||
# Add cumulative distance
|
||||
total_dist = 0.0
|
||||
prev = None
|
||||
for p in data_points:
|
||||
if p["latitude"] and p["longitude"]:
|
||||
if prev:
|
||||
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
|
||||
prev = (p["latitude"], p["longitude"])
|
||||
p["distance_m"] = total_dist
|
||||
|
||||
uphill, downhill = 0.0, 0.0
|
||||
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
|
||||
for i in range(1, len(alts)):
|
||||
diff = alts[i] - alts[i-1]
|
||||
if diff > 0:
|
||||
uphill += diff
|
||||
else:
|
||||
downhill += abs(diff)
|
||||
|
||||
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
|
||||
start_time_str = data_points[0]["timestamp"] if data_points else None
|
||||
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
|
||||
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
|
||||
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
|
||||
|
||||
sport = "running" # GPX doesn't always include sport; default to running
|
||||
if track.type:
|
||||
sport = track.type.lower()
|
||||
|
||||
return {
|
||||
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
|
||||
"sport_type": sport,
|
||||
"start_time": start_time_str,
|
||||
"distance_m": total_dist,
|
||||
"duration_s": duration,
|
||||
"elevation_gain_m": uphill,
|
||||
"elevation_loss_m": downhill,
|
||||
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
|
||||
"max_heart_rate": max(hrs) if hrs else None,
|
||||
"avg_cadence": None,
|
||||
"avg_power": None,
|
||||
"normalized_power": None,
|
||||
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
|
||||
"max_speed_ms": None,
|
||||
"avg_temperature_c": None,
|
||||
"calories": None,
|
||||
"training_stress_score": None,
|
||||
"vo2max_estimate": None,
|
||||
"polyline": encoded_polyline,
|
||||
"bounding_box": bounding_box,
|
||||
"source_type": "gpx",
|
||||
"data_points": data_points,
|
||||
"laps": [],
|
||||
}
|
||||
|
||||
|
||||
def parse_strava_export(export_dir: str) -> list[dict]:
|
||||
"""
|
||||
Parse a full Strava data export directory.
|
||||
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
|
||||
"""
|
||||
activities = []
|
||||
activities_dir = Path(export_dir) / "activities"
|
||||
|
||||
if not activities_dir.exists():
|
||||
return activities
|
||||
|
||||
for fname in sorted(activities_dir.iterdir()):
|
||||
if fname.suffix in (".fit", ".gpx"):
|
||||
try:
|
||||
if fname.suffix == ".fit":
|
||||
act = parse_fit_file(str(fname))
|
||||
else:
|
||||
act = parse_gpx_file(str(fname))
|
||||
act["source_type"] = "strava_" + fname.suffix[1:]
|
||||
activities.append(act)
|
||||
except Exception as e:
|
||||
print(f"Error parsing {fname}: {e}")
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict:
|
||||
"""Calculate percentage of time spent in each HR zone."""
|
||||
if not max_hr:
|
||||
return {}
|
||||
|
||||
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
|
||||
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||
total = 0
|
||||
|
||||
for p in data_points:
|
||||
hr = p.get("heart_rate")
|
||||
if not hr:
|
||||
continue
|
||||
pct = hr / max_hr
|
||||
total += 1
|
||||
if pct < zone_bounds[1]:
|
||||
zones["z1"] += 1
|
||||
elif pct < zone_bounds[2]:
|
||||
zones["z2"] += 1
|
||||
elif pct < zone_bounds[3]:
|
||||
zones["z3"] += 1
|
||||
elif pct < zone_bounds[4]:
|
||||
zones["z4"] += 1
|
||||
else:
|
||||
zones["z5"] += 1
|
||||
|
||||
if total:
|
||||
return {k: round(v / total * 100, 1) for k, v in zones.items()}
|
||||
return {}
|
||||
|
||||
|
||||
def _safe_float(val) -> Optional[float]:
|
||||
try:
|
||||
return float(val) if val is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _bounding_box(coords: list[tuple]) -> Optional[dict]:
|
||||
if not coords:
|
||||
return None
|
||||
lats = [c[0] for c in coords]
|
||||
lons = [c[1] for c in coords]
|
||||
return {
|
||||
"min_lat": min(lats), "max_lat": max(lats),
|
||||
"min_lon": min(lons), "max_lon": max(lons),
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Route matching: identifies when multiple activities were on the same route.
|
||||
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
|
||||
"""
|
||||
import math
|
||||
from typing import Optional
|
||||
import polyline as polyline_lib
|
||||
import numpy as np
|
||||
|
||||
|
||||
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
|
||||
return polyline_lib.decode(encoded)
|
||||
|
||||
|
||||
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
|
||||
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
|
||||
return (
|
||||
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
|
||||
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
|
||||
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
|
||||
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
|
||||
)
|
||||
|
||||
|
||||
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
|
||||
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
|
||||
if len(coords) <= n:
|
||||
return coords
|
||||
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
|
||||
return [coords[i] for i in indices]
|
||||
|
||||
|
||||
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
|
||||
"""
|
||||
Compute DTW distance between two GPS tracks.
|
||||
Each point is (lat, lon). Returns average distance in metres per matched pair.
|
||||
"""
|
||||
n, m = len(track1), len(track2)
|
||||
dtw = np.full((n + 1, m + 1), np.inf)
|
||||
dtw[0][0] = 0.0
|
||||
|
||||
for i in range(1, n + 1):
|
||||
for j in range(1, m + 1):
|
||||
cost = haversine_m(track1[i-1], track2[j-1])
|
||||
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
|
||||
|
||||
return dtw[n][m] / max(n, m)
|
||||
|
||||
|
||||
def haversine_m(p1: tuple, p2: tuple) -> float:
|
||||
R = 6371000
|
||||
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
|
||||
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
|
||||
return 2 * R * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def routes_are_similar(
|
||||
poly1: str,
|
||||
poly2: str,
|
||||
bb1: Optional[dict],
|
||||
bb2: Optional[dict],
|
||||
dtw_threshold_m: float = 80.0,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if two activities are on sufficiently similar routes.
|
||||
First does a cheap bounding box check, then DTW on downsampled tracks.
|
||||
"""
|
||||
if bb1 and bb2:
|
||||
if not bounding_boxes_overlap(bb1, bb2):
|
||||
return False
|
||||
|
||||
try:
|
||||
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
|
||||
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not coords1 or not coords2:
|
||||
return False
|
||||
|
||||
dist = dtw_distance(coords1, coords2)
|
||||
return dist < dtw_threshold_m
|
||||
|
||||
|
||||
def find_segment_times(
|
||||
data_points: list[dict],
|
||||
start_dist_m: float,
|
||||
end_dist_m: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Given activity data points (with cumulative distance_m),
|
||||
find the time to traverse from start_dist_m to end_dist_m.
|
||||
Returns duration in seconds, or None if not found.
|
||||
"""
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
for p in data_points:
|
||||
dist = p.get("distance_m")
|
||||
ts = p.get("timestamp")
|
||||
if dist is None or ts is None:
|
||||
continue
|
||||
|
||||
if start_time is None and dist >= start_dist_m:
|
||||
start_time = ts
|
||||
|
||||
if start_time is not None and dist >= end_dist_m:
|
||||
end_time = ts
|
||||
break
|
||||
|
||||
if start_time and end_time:
|
||||
from datetime import datetime
|
||||
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
|
||||
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
||||
return (t2 - t1).total_seconds()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_best_split_time(
|
||||
data_points: list[dict],
|
||||
target_distance_m: float,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Find the best (fastest) time over any target_distance_m window within an activity.
|
||||
E.g. fastest 1km split in a 10km run.
|
||||
Returns duration in seconds.
|
||||
"""
|
||||
points_with_dist = [
|
||||
p for p in data_points
|
||||
if p.get("distance_m") is not None and p.get("timestamp") is not None
|
||||
]
|
||||
|
||||
if not points_with_dist:
|
||||
return None
|
||||
|
||||
best = None
|
||||
j = 0
|
||||
|
||||
for i, start_p in enumerate(points_with_dist):
|
||||
start_dist = start_p["distance_m"]
|
||||
start_ts = start_p["timestamp"]
|
||||
|
||||
# Advance j until distance covered >= target
|
||||
while j < len(points_with_dist):
|
||||
end_p = points_with_dist[j]
|
||||
covered = end_p["distance_m"] - start_dist
|
||||
if covered >= target_distance_m:
|
||||
from datetime import datetime
|
||||
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
|
||||
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
|
||||
duration = (t2 - t1).total_seconds()
|
||||
if best is None or duration < best:
|
||||
best = duration
|
||||
break
|
||||
j += 1
|
||||
|
||||
if j >= len(points_with_dist):
|
||||
break
|
||||
|
||||
return best
|
||||
|
||||
|
||||
STANDARD_DISTANCES = [
|
||||
(400, "400m"),
|
||||
(800, "800m"),
|
||||
(1000, "1k"),
|
||||
(1609.34, "1 mile"),
|
||||
(3000, "3k"),
|
||||
(5000, "5k"),
|
||||
(10000, "10k"),
|
||||
(21097.5, "Half marathon"),
|
||||
(42195, "Marathon"),
|
||||
(50000, "50k"),
|
||||
(100000, "100k"),
|
||||
]
|
||||
|
||||
|
||||
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
|
||||
"""Compute best split times for all standard distances that fit within the activity."""
|
||||
results = {}
|
||||
for dist_m, label in STANDARD_DISTANCES:
|
||||
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
|
||||
best = find_best_split_time(data_points, dist_m)
|
||||
if best:
|
||||
results[label] = best
|
||||
return results
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Background tasks: activity ingestion, route matching, PR calculation.
|
||||
"""
|
||||
import asyncio
|
||||
from celery import Celery
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"fittracker",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
result_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
)
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="process_activity_file")
|
||||
def process_activity_file(self, file_path: str, user_id: int, source_type: str):
|
||||
"""Parse a FIT/GPX file and insert activity + data points into DB."""
|
||||
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
|
||||
from app.services.route_matcher import compute_best_splits, routes_are_similar
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.user import Activity, ActivityDataPoint, ActivityLap, PersonalRecord, HealthMetric
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"step": "parsing"})
|
||||
|
||||
try:
|
||||
if source_type == "fit" or file_path.endswith(".fit"):
|
||||
parsed = parse_fit_file(file_path)
|
||||
else:
|
||||
parsed = parse_gpx_file(file_path)
|
||||
except Exception as e:
|
||||
raise self.retry(exc=e, countdown=10, max_retries=3)
|
||||
|
||||
async def _insert():
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Check for duplicate
|
||||
if parsed.get("garmin_activity_id"):
|
||||
existing = await db.execute(
|
||||
select(Activity).where(
|
||||
Activity.garmin_activity_id == parsed["garmin_activity_id"]
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
# HR zones
|
||||
hr_zones = calculate_hr_zones(
|
||||
parsed.get("data_points", []),
|
||||
parsed.get("max_heart_rate") or 190
|
||||
)
|
||||
|
||||
# Create activity
|
||||
start_time = datetime.fromisoformat(parsed["start_time"]) if parsed.get("start_time") else None
|
||||
|
||||
activity = Activity(
|
||||
user_id=user_id,
|
||||
name=parsed["name"],
|
||||
sport_type=parsed["sport_type"],
|
||||
start_time=start_time,
|
||||
distance_m=parsed.get("distance_m"),
|
||||
duration_s=parsed.get("duration_s"),
|
||||
elevation_gain_m=parsed.get("elevation_gain_m"),
|
||||
elevation_loss_m=parsed.get("elevation_loss_m"),
|
||||
avg_heart_rate=parsed.get("avg_heart_rate"),
|
||||
max_heart_rate=parsed.get("max_heart_rate"),
|
||||
avg_cadence=parsed.get("avg_cadence"),
|
||||
avg_power=parsed.get("avg_power"),
|
||||
normalized_power=parsed.get("normalized_power"),
|
||||
avg_speed_ms=parsed.get("avg_speed_ms"),
|
||||
max_speed_ms=parsed.get("max_speed_ms"),
|
||||
avg_temperature_c=parsed.get("avg_temperature_c"),
|
||||
calories=parsed.get("calories"),
|
||||
training_stress_score=parsed.get("training_stress_score"),
|
||||
polyline=parsed.get("polyline"),
|
||||
bounding_box=parsed.get("bounding_box"),
|
||||
source_file=file_path,
|
||||
source_type=parsed.get("source_type"),
|
||||
hr_zones=hr_zones,
|
||||
)
|
||||
db.add(activity)
|
||||
await db.flush()
|
||||
|
||||
# Insert data points in batches
|
||||
points = parsed.get("data_points", [])
|
||||
batch_size = 500
|
||||
for i in range(0, len(points), batch_size):
|
||||
batch = points[i:i+batch_size]
|
||||
db.add_all([
|
||||
ActivityDataPoint(
|
||||
activity_id=activity.id,
|
||||
timestamp=datetime.fromisoformat(p["timestamp"]) if p.get("timestamp") else None,
|
||||
latitude=p.get("latitude"),
|
||||
longitude=p.get("longitude"),
|
||||
altitude_m=p.get("altitude_m"),
|
||||
heart_rate=p.get("heart_rate"),
|
||||
cadence=p.get("cadence"),
|
||||
speed_ms=p.get("speed_ms"),
|
||||
power=p.get("power"),
|
||||
temperature_c=p.get("temperature_c"),
|
||||
distance_m=p.get("distance_m"),
|
||||
)
|
||||
for p in batch
|
||||
])
|
||||
|
||||
# Insert laps
|
||||
for lap in parsed.get("laps", []):
|
||||
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
|
||||
db.add(ActivityLap(
|
||||
activity_id=activity.id,
|
||||
lap_number=lap["lap_number"],
|
||||
start_time=ls,
|
||||
duration_s=lap.get("duration_s"),
|
||||
distance_m=lap.get("distance_m"),
|
||||
avg_heart_rate=lap.get("avg_heart_rate"),
|
||||
avg_cadence=lap.get("avg_cadence"),
|
||||
avg_speed_ms=lap.get("avg_speed_ms"),
|
||||
avg_power=lap.get("avg_power"),
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return activity.id
|
||||
|
||||
activity_id = run_async(_insert())
|
||||
|
||||
if activity_id:
|
||||
# Queue PR calculation
|
||||
compute_personal_records.delay(activity_id, user_id, parsed)
|
||||
|
||||
return {"activity_id": activity_id, "status": "ok"}
|
||||
|
||||
|
||||
@celery_app.task(name="compute_personal_records")
|
||||
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
|
||||
"""Calculate personal records for standard distances from this activity."""
|
||||
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.user import PersonalRecord
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone
|
||||
|
||||
data_points = parsed.get("data_points", [])
|
||||
total_dist = parsed.get("distance_m", 0) or 0
|
||||
sport = parsed.get("sport_type", "running")
|
||||
start_time_str = parsed.get("start_time")
|
||||
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
|
||||
|
||||
best_splits = compute_best_splits(data_points, total_dist)
|
||||
|
||||
async def _save():
|
||||
async with AsyncSessionLocal() as db:
|
||||
for label, duration_s in best_splits.items():
|
||||
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
|
||||
if dist_m is None:
|
||||
continue
|
||||
|
||||
# Check existing record
|
||||
existing = await db.execute(
|
||||
select(PersonalRecord).where(
|
||||
PersonalRecord.user_id == user_id,
|
||||
PersonalRecord.sport_type == sport,
|
||||
PersonalRecord.distance_m == dist_m,
|
||||
PersonalRecord.is_current_record == True,
|
||||
)
|
||||
)
|
||||
current = existing.scalar_one_or_none()
|
||||
|
||||
if current is None or duration_s < current.duration_s:
|
||||
if current:
|
||||
current.is_current_record = False
|
||||
db.add(PersonalRecord(
|
||||
user_id=user_id,
|
||||
activity_id=activity_id,
|
||||
sport_type=sport,
|
||||
distance_m=dist_m,
|
||||
distance_label=label,
|
||||
duration_s=duration_s,
|
||||
achieved_at=start_time,
|
||||
is_current_record=True,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
run_async(_save())
|
||||
|
||||
|
||||
@celery_app.task(name="process_garmin_health_zip")
|
||||
def process_garmin_health_zip(zip_path: str, user_id: int):
|
||||
"""
|
||||
Process a Garmin Connect data export zip.
|
||||
Extracts wellness/sleep/HRV CSV files and inserts health metrics.
|
||||
"""
|
||||
import zipfile
|
||||
import json
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.user import HealthMetric
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async def _process():
|
||||
async with AsyncSessionLocal() as db:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
# Parse daily summary JSON files from Garmin export
|
||||
for name in names:
|
||||
if "DailyMetrics" in name and name.endswith(".json"):
|
||||
with zf.open(name) as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
date_str = data.get("calendarDate") or data.get("date")
|
||||
if not date_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
date = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
metric = HealthMetric(
|
||||
user_id=user_id,
|
||||
date=date,
|
||||
resting_hr=data.get("restingHeartRate"),
|
||||
steps=data.get("totalSteps"),
|
||||
floors_climbed=data.get("floorsAscended"),
|
||||
active_calories=data.get("activeKilocalories"),
|
||||
total_calories=data.get("totalKilocalories"),
|
||||
avg_stress=data.get("averageStressLevel"),
|
||||
spo2_avg=data.get("avgSpo2"),
|
||||
)
|
||||
db.add(metric)
|
||||
|
||||
await db.commit()
|
||||
|
||||
run_async(_process())
|
||||
Reference in New Issue
Block a user