Files
MileVault/backend/app/api/profile.py
T
owain bc437cce92
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
Batch 1: dashboard, maps, segments rewrite, health, sync UX
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:59:06 +01:00

246 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
biological_sex: Optional[str] = None
goal_weight_kg: 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]
biological_sex: Optional[str]
goal_weight_kg: 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),
):
old_max_hr = current_user.max_heart_rate
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
if body.biological_sex is not None:
if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None
if body.goal_weight_kg is not None:
if body.goal_weight_kg and not (20 <= body.goal_weight_kg <= 500):
raise HTTPException(400, "Goal weight must be 20500 kg")
current_user.goal_weight_kg = body.goal_weight_kg or None
await db.commit()
await db.refresh(current_user)
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
from app.workers.tasks import recalculate_hr_zones_for_user
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
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
allowed_group: 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
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"allowed_group": allowed_group or "",
"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
# Only overwrite the secret when a non-empty value is supplied; a blank
# field means "keep the existing secret" (matches the UI hint).
if body.client_secret:
current_user.pocketid_client_secret = body.client_secret
if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() 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()