All tweaks added
This commit is contained in:
@@ -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 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
|
||||
|
||||
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 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()
|
||||
Reference in New Issue
Block a user