Initial Commit
This commit is contained in:
@@ -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"}
|
||||
Reference in New Issue
Block a user