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 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] 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 100–250") 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 20–120") 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 50–300 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 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 20–500 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()