All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
@@ -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()
+122
View File
@@ -0,0 +1,122 @@
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, get_current_user
from app.core.config import settings
from app.models.user import User
router = APIRouter()
async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars."""
result = await db.execute(select(User).where(User.is_admin == True).limit(1))
admin = result.scalar_one_or_none()
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
return issuer, client_id, client_secret
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(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
return {"available": bool(issuer and client_id)}
@router.get("/pocketid/login-url")
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{issuer}/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"client_id": client_id, "client_secret": 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"{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)})
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}")
+156
View File
@@ -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"}
+220
View File
@@ -0,0 +1,220 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date, timezone
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, verify_password
from app.models.user import User, WeightLog
router = APIRouter()
# ── Profile ────────────────────────────────────────────────────────────────
class ProfileUpdate(BaseModel):
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
class ProfileOut(BaseModel):
id: int
username: str
email: Optional[str]
max_heart_rate: Optional[int]
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
class Config:
from_attributes = True
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
return None
@router.get("/", response_model=ProfileOut)
async def get_profile(current_user: User = Depends(get_current_user)):
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
current_user.max_heart_rate = body.max_heart_rate
if body.resting_heart_rate is not None:
if not (20 <= body.resting_heart_rate <= 120):
raise HTTPException(400, "Resting HR must be 20120")
current_user.resting_heart_rate = body.resting_heart_rate
if body.birth_year is not None:
if not (1920 <= body.birth_year <= 2010):
raise HTTPException(400, "Invalid birth year")
current_user.birth_year = body.birth_year
if body.height_cm is not None:
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
await db.commit()
await db.refresh(current_user)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
# ── Password change ────────────────────────────────────────────────────────
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.hashed_password:
raise HTTPException(400, "Account uses passkey login — no password to change")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(400, "Current password is incorrect")
if len(body.new_password) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
# ── PocketID configuration (admin only) ────────────────────────────────────
class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
@router.get("/pocketid-config")
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
from app.core.config import settings
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"enabled": bool(issuer and client_id),
}
@router.post("/pocketid-config")
async def save_pocketid_config(
body: PocketIDConfig,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
if body.issuer is not None:
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None
if body.client_secret is not None:
current_user.pocketid_client_secret = body.client_secret or None
await db.commit()
return {"status": "ok"}
# ── Weight log ─────────────────────────────────────────────────────────────
class WeightEntry(BaseModel):
date: datetime
weight_kg: float
body_fat_pct: Optional[float] = None
note: Optional[str] = None
class WeightOut(BaseModel):
id: int
date: datetime
weight_kg: float
body_fat_pct: Optional[float]
note: Optional[str]
class Config:
from_attributes = True
@router.get("/weight", response_model=List[WeightOut])
async def list_weight(
limit: int = 365,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog)
.where(WeightLog.user_id == current_user.id)
.order_by(desc(WeightLog.date))
.limit(limit)
)
return result.scalars().all()
@router.post("/weight", response_model=WeightOut)
async def log_weight(
body: WeightEntry,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not (20 <= body.weight_kg <= 500):
raise HTTPException(400, "Weight must be 20500 kg")
entry = WeightLog(
user_id=current_user.id,
date=body.date,
weight_kg=body.weight_kg,
body_fat_pct=body.body_fat_pct,
note=body.note,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.delete("/weight/{entry_id}", status_code=204)
async def delete_weight(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog).where(
WeightLog.id == entry_id,
WeightLog.user_id == current_user.id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Not found")
await db.delete(entry)
await db.commit()
@@ -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()
+232
View File
@@ -0,0 +1,232 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, RouteSegment, Activity
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int
class RouteOut(BaseModel):
id: int
name: str
description: Optional[str]
sport_type: Optional[str]
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
class Config:
from_attributes = True
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
async def create_route(
body: RouteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
route = NamedRoute(
user_id=current_user.id,
name=body.name,
description=body.description,
sport_type=body.sport_type or activity.sport_type,
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}", response_model=RouteOut)
async def get_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
Activity.user_id == current_user.id,
).order_by(Activity.duration_s)
)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"start_time": a.start_time,
"duration_s": a.duration_s,
"distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms,
}
for a in activities
]
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
+134
View File
@@ -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,
}