Files
Home_assistant/backend/app/api/routes/fitness.py
T
2026-07-01 11:20:13 +03:00

559 lines
20 KiB
Python

from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.auth.deps import get_current_user
from app.db.base import get_db
from app.db.models import User
from app.fitness.gamification import build_gamification
from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout
from app.integrations.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient
router = APIRouter()
class ProfileUpdate(BaseModel):
sex: str | None = None
age: int | None = None
height_cm: float | None = None
weight_kg: float | None = None
goal: str | None = None
target_weight_kg: float | None = None
neat_base_kcal: float | None = Field(default=None, ge=200, le=300)
activity_level: str | None = None
weekly_workouts: int | None = Field(default=None, ge=0, le=14)
baseline_steps: int | None = Field(default=None, ge=0)
baseline_workout_kcal: float | None = Field(default=None, ge=0)
class MealCreate(BaseModel):
text: str = Field(min_length=1)
meal_type: str | None = None
class WaterCreate(BaseModel):
amount_ml: int = Field(gt=0)
class WeightCreate(BaseModel):
weight_kg: float = Field(gt=0)
body_fat_pct: float | None = None
chest_cm: float | None = None
waist_cm: float | None = None
neck_cm: float | None = None
hip_cm: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
recorded_at: str | None = None
class BodyMetricUpdate(BaseModel):
weight_kg: float | None = Field(default=None, gt=0)
body_fat_pct: float | None = Field(default=None, ge=0, le=100)
chest_cm: float | None = Field(default=None, ge=0)
waist_cm: float | None = Field(default=None, ge=0)
neck_cm: float | None = Field(default=None, ge=0)
hip_cm: float | None = Field(default=None, ge=0)
notes: str | None = None
recorded_at: str | None = None
class BodyCompositionCalc(BaseModel):
weight_kg: float | None = None
height_cm: float | None = None
sex: str | None = None
neck_cm: float | None = None
waist_cm: float | None = None
hip_cm: float | None = None
body_fat_pct: float | None = None
class StepsCreate(BaseModel):
steps: int = Field(ge=0)
active_calories: float | None = None
notes: str = ""
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
replace: bool = False
class StepsUpdate(BaseModel):
steps: int | None = Field(default=None, ge=0)
active_calories: float | None = None
notes: str | None = None
class MealUpdate(BaseModel):
description: str | None = None
meal_type: str | None = None
calories: float | None = Field(default=None, ge=0)
protein_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fiber_g: float | None = Field(default=None, ge=0)
estimated: bool | None = None
class WaterUpdate(BaseModel):
amount_ml: int | None = Field(default=None, ge=0)
class LogMerge(BaseModel):
kind: str = Field(description="meal|water|steps")
keep_id: int
merge_id: int
class DeleteLastLog(BaseModel):
kind: str = Field(description="meal|water|steps|workout")
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
class WorkoutCreate(BaseModel):
text: str = Field(min_length=1)
day: str | None = None
days_ago: int | None = Field(default=None, ge=0, le=90)
logged_at: str | None = None
class WorkoutResultsUpdate(BaseModel):
distance_m: float | None = Field(default=None, ge=0)
avg_heart_rate: int | None = Field(default=None, ge=30, le=250)
max_heart_rate: int | None = Field(default=None, ge=30, le=250)
avg_pace: float | None = Field(default=None, ge=0)
avg_cadence: int | None = Field(default=None, ge=0)
max_cadence: int | None = Field(default=None, ge=0)
avg_stride_length_cm: float | None = Field(default=None, ge=0)
training_load: float | None = Field(default=None, ge=0)
aerobic_effect: float | None = Field(default=None, ge=0)
anaerobic_effect: float | None = Field(default=None, ge=0)
vo2max: float | None = Field(default=None, ge=0)
recovery_hours: float | None = Field(default=None, ge=0)
intervals: list[dict[str, Any]] | dict[str, Any] | None = None
heart_rate_zones: list[dict[str, Any]] | dict[str, Any] | None = None
perceived_effort: int | None = Field(default=None, ge=1, le=5)
exercises: list[dict[str, Any]] | None = None
@field_validator("intervals", "heart_rate_zones")
@classmethod
def validate_json_payload(cls, value: Any) -> Any:
if value is None:
return value
if not isinstance(value, (list, dict)):
raise ValueError("must be a JSON array or object")
return value
class ReminderUpdate(BaseModel):
enabled: bool | None = None
hour: int | None = Field(default=None, ge=0, le=23)
minute: int | None = Field(default=None, ge=0, le=59)
interval_hours: int | None = Field(default=None, ge=1, le=12)
@router.get("/fitness")
def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
return FitnessService(db, user.id).snapshot()
@router.get("/fitness/summary")
def get_summary(
day: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
d = date.fromisoformat(day) if day else None
return FitnessService(db, user.id).get_daily_summary(d)
@router.get("/fitness/workout-stats")
def get_workout_stats(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day)
@router.get("/fitness/history")
def get_history(
days: int = 7,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
@router.get("/fitness/charts")
def get_charts(
weeks: int = 52,
trend: bool = True,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_charts(weeks=weeks, trend=trend, end_day=end_day)
@router.get("/fitness/gamification")
def get_gamification(
end: str | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return build_gamification(db, user.id, end_day=end_day)
@router.get("/fitness/compare")
def compare_days(
dates: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
parsed: list[date] = []
for part in dates.split(","):
part = part.strip()
if part:
parsed.append(date.fromisoformat(part))
if len(parsed) < 2:
raise HTTPException(status_code=400, detail="Provide at least two dates (YYYY-MM-DD,YYYY-MM-DD)")
if len(parsed) > 14:
raise HTTPException(status_code=400, detail="At most 14 dates")
return FitnessService(db, user.id).compare_days(parsed)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
profile = FitnessService(db, user.id).get_profile()
return profile or {"configured": False}
@router.put("/fitness/profile")
def update_profile(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True))
@router.post("/fitness/profile/calc")
def calc_targets(
payload: ProfileUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
params = payload.model_dump(exclude_none=True)
if not params:
raise HTTPException(status_code=400, detail="No parameters")
return FitnessService(db, user.id).calc_targets(params)
@router.post("/fitness/meals")
async def create_meal(
payload: MealCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_meal(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return service.log_meal(
description=structured.get("description") or payload.text,
meal_type=payload.meal_type or structured.get("meal_type") or "snack",
calories=float(structured.get("calories") or 0),
protein_g=float(structured.get("protein_g") or 0),
fat_g=float(structured.get("fat_g") or 0),
carbs_g=float(structured.get("carbs_g") or 0),
fiber_g=float(structured.get("fiber_g") or 0),
source="llm",
estimated=bool(structured.get("estimated", True)),
)
@router.post("/fitness/water")
def create_water(
payload: WaterCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).log_water(payload.amount_ml)
@router.post("/fitness/weight")
def create_weight(
payload: WeightCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_weight(
payload.weight_kg,
body_fat_pct=payload.body_fat_pct,
chest_cm=payload.chest_cm,
waist_cm=payload.waist_cm,
neck_cm=payload.neck_cm,
hip_cm=payload.hip_cm,
notes=payload.notes,
recorded_at=payload.recorded_at,
day=day,
days_ago=payload.days_ago,
)
@router.post("/fitness/body-composition/calc")
def calc_body_composition(
payload: BodyCompositionCalc,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True))
@router.post("/fitness/steps")
def create_steps(
payload: StepsCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
return FitnessService(db, user.id).log_steps(
payload.steps,
active_calories=payload.active_calories,
notes=payload.notes,
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
replace=payload.replace,
)
@router.patch("/fitness/steps/{log_id}")
def update_steps(
log_id: int,
payload: StepsUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
result = FitnessService(db, user.id).update_step_log(log_id, updates)
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.delete("/fitness/steps/{log_id}")
def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_step_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.post("/fitness/workouts")
async def create_workout(
payload: WorkoutCreate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
try:
structured = await structure_workout(payload.text)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
day = date.fromisoformat(payload.day) if payload.day else None
return service.log_workout(
title=structured.get("title") or "Тренировка",
notes=structured.get("notes") or payload.text,
duration_min=structured.get("duration_min"),
exercises=structured.get("exercises"),
active_calories=structured.get("active_calories"),
total_calories=structured.get("total_calories"),
steps=structured.get("steps"),
activity_type=structured.get("activity_type"),
met=structured.get("met"),
day=day,
days_ago=payload.days_ago,
logged_at=payload.logged_at,
)
@router.get("/fitness/workouts/{log_id}/results")
def get_workout_results(
log_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
result = FitnessService(db, user.id).get_workout_results(log_id)
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.post("/fitness/workouts/{log_id}/results")
def save_workout_results(
log_id: int,
payload: WorkoutResultsUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
service = FitnessService(db, user.id)
try:
result = service.save_workout_results(log_id, updates)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.get("/fitness/body-metrics")
def list_metrics(
limit: int = 30,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_body_metrics(limit=limit)
@router.patch("/fitness/body-metrics/{metric_id}")
def update_body_metric(
metric_id: int,
payload: BodyMetricUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
result = FitnessService(db, user.id).update_body_metric(metric_id, updates)
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.patch("/fitness/meals/{log_id}")
def update_meal(
log_id: int,
payload: MealUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
result = FitnessService(db, user.id).update_food_log(log_id, updates)
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.patch("/fitness/water/{log_id}")
def update_water(
log_id: int,
payload: WaterUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
result = FitnessService(db, user.id).update_water_log(log_id, updates)
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.post("/fitness/logs/merge")
def merge_logs(
payload: LogMerge,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
service = FitnessService(db, user.id)
kind = payload.kind.lower().strip()
if kind in ("meal", "meals", "food"):
result = service.merge_food_logs(payload.keep_id, payload.merge_id)
elif kind == "water":
result = service.merge_water_logs(payload.keep_id, payload.merge_id)
elif kind in ("step", "steps"):
result = service.merge_step_logs(payload.keep_id, payload.merge_id)
else:
raise HTTPException(status_code=400, detail="Unsupported kind")
if not result:
raise HTTPException(status_code=404, detail="Not found")
return result
@router.post("/fitness/logs/delete-last")
def delete_last_log(
payload: DeleteLastLog,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict[str, Any]:
day = date.fromisoformat(payload.day) if payload.day else None
if day is None and payload.days_ago is not None:
day = datetime.now(timezone.utc).date() - timedelta(days=int(payload.days_ago))
result = FitnessService(db, user.id).delete_last_log(payload.kind, day=day)
if not result:
raise HTTPException(status_code=404, detail="No records found")
return result
@router.delete("/fitness/meals/{log_id}")
def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_food_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/water/{log_id}")
def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_water_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.delete("/fitness/workouts/{log_id}")
def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]:
if not FitnessService(db, user.id).delete_workout_log(log_id):
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
@router.get("/fitness/reminders")
def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]:
return FitnessService(db, user.id).list_reminders()
@router.put("/fitness/reminders/{kind}")
def update_reminder(
kind: str,
payload: ReminderUpdate,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
return FitnessService(db, user.id).set_reminder(
kind,
enabled=payload.enabled,
hour=payload.hour,
minute=payload.minute,
interval_hours=payload.interval_hours,
)
@router.get("/fitness/lookup/food")
def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]:
return OpenFoodFactsClient().search(q, limit=limit)
@router.get("/fitness/lookup/exercise")
def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]:
return WgerClient().search_exercises(q, limit=limit)