Files
lifetrack/apps/api/app/modules/health/schemas.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

605 lines
18 KiB
Python

"""Pydantic v2 schemas for the health module (CONVENTIONS C2.5).
Numeric columns are exposed as `float` on purpose: the API feeds ECharts
directly and JSON numbers are required (Pydantic serialises Decimal as a
string). Precision stays in the database (Numeric columns).
"""
import datetime as dt
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.core.timeutils import UtcDatetime
from app.modules.health.models import (
ActivityLevel,
GoalMode,
GoalStatus,
MealType,
ScheduleKind,
Sex,
SportType,
)
ORM = ConfigDict(from_attributes=True)
# --- Profile ------------------------------------------------------------------
class HealthProfileUpdate(BaseModel):
"""PUT /health/profile — full singleton upsert."""
height_cm: float = Field(gt=0, lt=300)
sex: Sex
birthdate: dt.date
activity_level: ActivityLevel = ActivityLevel.SEDENTARY
timezone: str = Field(default="Europe/Paris", max_length=64)
water_goal_ml: int | None = Field(default=2000, ge=0, le=20000)
calorie_floor_kcal: int | None = Field(default=None, ge=800, le=5000)
class HealthProfileRead(BaseModel):
model_config = ORM
id: int
height_cm: float
sex: Sex
birthdate: dt.date
activity_level: ActivityLevel
timezone: str
water_goal_ml: int | None
calorie_floor_kcal: int | None
# Derived (not columns)
age: int | None = None
bmr_kcal: float | None = None
tdee_estimated_kcal: float | None = None
current_weight_kg: float | None = None
bmi: float | None = None
# --- Weights ------------------------------------------------------------------
class WeightEntryCreate(BaseModel):
measured_at: UtcDatetime
weight_kg: float = Field(gt=20, lt=400)
body_fat_pct: float | None = Field(default=None, ge=0, le=100)
muscle_mass_kg: float | None = Field(default=None, ge=0, le=200)
water_pct: float | None = Field(default=None, ge=0, le=100)
note: str | None = Field(default=None, max_length=255)
class WeightEntryUpdate(BaseModel):
measured_at: UtcDatetime | None = None
weight_kg: float | None = Field(default=None, gt=20, lt=400)
body_fat_pct: float | None = Field(default=None, ge=0, le=100)
muscle_mass_kg: float | None = Field(default=None, ge=0, le=200)
water_pct: float | None = Field(default=None, ge=0, le=100)
note: str | None = Field(default=None, max_length=255)
class WeightEntryRead(BaseModel):
model_config = ORM
id: int
measured_at: dt.datetime
weight_kg: float
body_fat_pct: float | None
muscle_mass_kg: float | None
water_pct: float | None
note: str | None
source: str
# --- Body measurements --------------------------------------------------------
MEASUREMENT_SITES: tuple[str, ...] = (
"neck_cm",
"chest_cm",
"waist_cm",
"hips_cm",
"biceps_left_cm",
"biceps_right_cm",
"thigh_left_cm",
"thigh_right_cm",
"calf_left_cm",
"calf_right_cm",
)
class BodyMeasurementCreate(BaseModel):
measured_at: UtcDatetime
neck_cm: float | None = Field(default=None, gt=0, lt=300)
chest_cm: float | None = Field(default=None, gt=0, lt=300)
waist_cm: float | None = Field(default=None, gt=0, lt=300)
hips_cm: float | None = Field(default=None, gt=0, lt=300)
biceps_left_cm: float | None = Field(default=None, gt=0, lt=300)
biceps_right_cm: float | None = Field(default=None, gt=0, lt=300)
thigh_left_cm: float | None = Field(default=None, gt=0, lt=300)
thigh_right_cm: float | None = Field(default=None, gt=0, lt=300)
calf_left_cm: float | None = Field(default=None, gt=0, lt=300)
calf_right_cm: float | None = Field(default=None, gt=0, lt=300)
note: str | None = Field(default=None, max_length=255)
class BodyMeasurementUpdate(BodyMeasurementCreate):
measured_at: UtcDatetime | None = None
class BodyMeasurementRead(BaseModel):
model_config = ORM
id: int
measured_at: dt.datetime
neck_cm: float | None
chest_cm: float | None
waist_cm: float | None
hips_cm: float | None
biceps_left_cm: float | None
biceps_right_cm: float | None
thigh_left_cm: float | None
thigh_right_cm: float | None
calf_left_cm: float | None
calf_right_cm: float | None
note: str | None
source: str
# --- Daily activity -----------------------------------------------------------
class DailyActivityUpsert(BaseModel):
"""POST /health/activity — manual upsert of one local day."""
date: dt.date
steps: int | None = Field(default=None, ge=0)
active_kcal: float | None = Field(default=None, ge=0)
total_kcal: float | None = Field(default=None, ge=0)
distance_m: int | None = Field(default=None, ge=0)
active_minutes: int | None = Field(default=None, ge=0)
floors: int | None = Field(default=None, ge=0)
class DailyActivityRead(BaseModel):
model_config = ORM
id: int
date: dt.date
steps: int | None
active_kcal: float | None
total_kcal: float | None
distance_m: int | None
active_minutes: int | None
floors: int | None
source: str
class MergedActivityRead(BaseModel):
"""Cross-source merged day (§3.5) with per-field provenance."""
date: dt.date
steps: int | None = None
active_kcal: float | None = None
total_kcal: float | None = None
distance_m: int | None = None
active_minutes: int | None = None
floors: int | None = None
field_sources: dict[str, str] = Field(default_factory=dict)
# --- Workouts -----------------------------------------------------------------
class WorkoutCreate(BaseModel):
started_at: UtcDatetime
ended_at: UtcDatetime
sport_type: SportType
sport_label: str | None = Field(default=None, max_length=100)
kcal: float | None = Field(default=None, ge=0)
distance_m: int | None = Field(default=None, ge=0)
steps: int | None = Field(default=None, ge=0)
avg_hr: int | None = Field(default=None, ge=0, le=300)
max_hr: int | None = Field(default=None, ge=0, le=300)
avg_speed_kmh: float | None = Field(default=None, ge=0, le=100)
elevation_m: int | None = None
note: str | None = Field(default=None, max_length=255)
class WorkoutUpdate(BaseModel):
started_at: UtcDatetime | None = None
ended_at: UtcDatetime | None = None
sport_type: SportType | None = None
sport_label: str | None = Field(default=None, max_length=100)
kcal: float | None = Field(default=None, ge=0)
distance_m: int | None = Field(default=None, ge=0)
steps: int | None = Field(default=None, ge=0)
avg_hr: int | None = Field(default=None, ge=0, le=300)
max_hr: int | None = Field(default=None, ge=0, le=300)
avg_speed_kmh: float | None = Field(default=None, ge=0, le=100)
elevation_m: int | None = None
note: str | None = Field(default=None, max_length=255)
is_hidden: bool | None = None
class WorkoutRead(BaseModel):
model_config = ORM
id: int
started_at: dt.datetime
ended_at: dt.datetime
duration_s: int
sport_type: SportType
sport_label: str | None
kcal: float | None
distance_m: int | None
steps: int | None
avg_hr: int | None
max_hr: int | None
avg_speed_kmh: float | None
elevation_m: int | None
is_hidden: bool
note: str | None
source: str
# --- Goals --------------------------------------------------------------------
class GoalCreate(BaseModel):
mode: GoalMode
start_date: dt.date | None = None
start_weight_kg: float | None = Field(default=None, gt=20, lt=400)
target_weight_kg: float = Field(gt=20, lt=400)
target_date: dt.date | None = None
weekly_rate_kg: float | None = Field(default=None, gt=-1.01, le=1.5)
note: str | None = Field(default=None, max_length=255)
class GoalUpdate(BaseModel):
mode: GoalMode | None = None
start_date: dt.date | None = None
start_weight_kg: float | None = Field(default=None, gt=20, lt=400)
target_weight_kg: float | None = Field(default=None, gt=20, lt=400)
target_date: dt.date | None = None
weekly_rate_kg: float | None = Field(default=None, gt=-1.01, le=1.5)
status: GoalStatus | None = None
note: str | None = Field(default=None, max_length=255)
class GoalRead(BaseModel):
model_config = ORM
id: int
mode: GoalMode
start_date: dt.date
start_weight_kg: float
target_weight_kg: float
target_date: dt.date | None
weekly_rate_kg: float | None
status: GoalStatus
note: str | None
class ProjectionRead(BaseModel):
status: str # "ok" | "reached" | "not_converging"
date: dt.date | None = None
class BudgetRead(BaseModel):
kcal: float
deficit_target_kcal: float
floor_applied: bool
rate_clamped: bool = False
tdee_kcal: float
tdee_method: str
class ActiveGoalRead(BaseModel):
goal: GoalRead | None = None
budget: BudgetRead | None = None
projection: ProjectionRead | None = None
trend_weight_kg: float | None = None
done_kg: float | None = None
remaining_kg: float | None = None
progress_pct: float | None = None
# --- Nutrition ----------------------------------------------------------------
class FoodEntryCreate(BaseModel):
eaten_at: UtcDatetime
meal: MealType
name: str = Field(min_length=1, max_length=200)
brand: str | None = Field(default=None, max_length=100)
quantity: float = Field(default=100.0, gt=0)
unit: str = Field(default="g", max_length=20)
kcal: float = Field(ge=0)
protein_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
fiber_g: float | None = Field(default=None, ge=0)
sugar_g: float | None = Field(default=None, ge=0)
sat_fat_g: float | None = Field(default=None, ge=0)
sodium_mg: float | None = Field(default=None, ge=0)
# Optional shortcut: build the entry from a favorite, prorated on quantity.
favorite_id: int | None = None
class FoodEntryUpdate(BaseModel):
eaten_at: UtcDatetime | None = None
meal: MealType | None = None
name: str | None = Field(default=None, min_length=1, max_length=200)
brand: str | None = Field(default=None, max_length=100)
quantity: float | None = Field(default=None, gt=0)
unit: str | None = Field(default=None, max_length=20)
kcal: float | None = Field(default=None, ge=0)
protein_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
fiber_g: float | None = Field(default=None, ge=0)
sugar_g: float | None = Field(default=None, ge=0)
sat_fat_g: float | None = Field(default=None, ge=0)
sodium_mg: float | None = Field(default=None, ge=0)
class FoodEntryRead(BaseModel):
model_config = ORM
id: int
eaten_at: dt.datetime
meal: MealType
name: str
brand: str | None
quantity: float
unit: str
kcal: float
protein_g: float | None
carbs_g: float | None
fat_g: float | None
fiber_g: float | None
sugar_g: float | None
sat_fat_g: float | None
sodium_mg: float | None
source: str
class FoodFavoriteCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
brand: str | None = Field(default=None, max_length=100)
default_quantity: float = Field(default=100.0, gt=0)
unit: str = Field(default="g", max_length=20)
kcal: float = Field(ge=0)
protein_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
fiber_g: float | None = Field(default=None, ge=0)
default_meal: MealType | None = None
class FoodFavoriteUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
brand: str | None = Field(default=None, max_length=100)
default_quantity: float | None = Field(default=None, gt=0)
unit: str | None = Field(default=None, max_length=20)
kcal: float | None = Field(default=None, ge=0)
protein_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
fiber_g: float | None = Field(default=None, ge=0)
default_meal: MealType | None = None
class FoodFavoriteRead(BaseModel):
model_config = ORM
id: int
name: str
brand: str | None
default_quantity: float
unit: str
kcal: float
protein_g: float | None
carbs_g: float | None
fat_g: float | None
fiber_g: float | None
default_meal: MealType | None
use_count: int
last_used_at: dt.datetime | None
class RecentFoodRead(BaseModel):
"""Distinct (name, brand) recently logged — computed from food_entries."""
name: str
brand: str | None = None
unit: str = "g"
quantity: float | None = None
kcal: float | None = None
protein_g: float | None = None
carbs_g: float | None = None
fat_g: float | None = None
meal: MealType | None = None
last_eaten_at: dt.datetime
class WaterEntryCreate(BaseModel):
drunk_at: UtcDatetime
volume_ml: int = Field(gt=0, le=5000)
class WaterEntryRead(BaseModel):
model_config = ORM
id: int
drunk_at: dt.datetime
volume_ml: int
source: str
class MealTotals(BaseModel):
meal: MealType
kcal: float = 0.0
protein_g: float = 0.0
carbs_g: float = 0.0
fat_g: float = 0.0
entries: list[FoodEntryRead] = Field(default_factory=list)
class NutritionDayRead(BaseModel):
date: dt.date
kcal: float | None = None
protein_g: float | None = None
carbs_g: float | None = None
fat_g: float | None = None
fiber_g: float | None = None
budget_kcal: float | None = None
vs_budget_kcal: float | None = None
class NutritionDayDetail(BaseModel):
date: dt.date
totals: NutritionDayRead
water_ml: int = 0
water_goal_ml: int | None = None
meals: list[MealTotals] = Field(default_factory=list)
# --- Food referential / search -------------------------------------------------
class FoodSearchItem(BaseModel):
model_config = ORM
id: int | None = None
source: str
source_id: str | None = None
name: str
brand: str | None = None
energy_kcal_100g: float | None = None
protein_g_100g: float | None = None
carbs_g_100g: float | None = None
sugar_g_100g: float | None = None
fat_g_100g: float | None = None
sat_fat_g_100g: float | None = None
fiber_g_100g: float | None = None
salt_g_100g: float | None = None
serving_size_g: float | None = None
class FoodSearchResponse(BaseModel):
query: str
items: list[FoodSearchItem] = Field(default_factory=list)
# "cache" when Open Food Facts was not called, "cache+off" otherwise.
origin: str = "cache"
# --- Planning (addendum-planning.md) ------------------------------------------
class ScheduleRead(BaseModel):
model_config = ORM
kind: ScheduleKind
weekdays: list[int] = Field(default_factory=list)
enabled: bool = False
class ScheduleUpdate(BaseModel):
weekdays: list[int] = Field(default_factory=list)
enabled: bool = True
class StreakRead(BaseModel):
current: int = 0
best: int = 0
class TodayItem(BaseModel):
kind: ScheduleKind
planned: bool
done: bool
value: float | None = None # kg / sessions count / kcal
class TodayResponse(BaseModel):
date: dt.date
items: list[TodayItem] = Field(default_factory=list)
streaks: dict[str, StreakRead] = Field(default_factory=dict)
class AdherenceDay(BaseModel):
date: dt.date
planned: bool
done: bool
status: str # "done" | "missed" | "done_unplanned" | "rest"
class AdherenceKind(BaseModel):
kind: ScheduleKind
weekdays: list[int] = Field(default_factory=list)
enabled: bool = False
planned_days: int = 0
done_days: int = 0
missed_days: int = 0
adherence_pct: float | None = None
streak: StreakRead = Field(default_factory=StreakRead)
days: list[AdherenceDay] = Field(default_factory=list)
# --- Chart-ready stats contract (§8.1) ----------------------------------------
class Series(BaseModel):
name: str
type: str # "line" | "bar" | "scatter" | "pie" | "heatmap"
points: list[list[Any]] = Field(default_factory=list) # [[date_iso, value|null]]
class StatsResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: dt.date = Field(alias="from")
to: dt.date
unit: str
series: list[Series] = Field(default_factory=list)
meta: dict[str, Any] = Field(default_factory=dict)
class AdherenceResponse(StatsResponse):
"""`/health/stats/adherence` — a `stats/*` endpoint, so it carries the §8.1
envelope (one calendar-heatmap series per habit) plus the richer per-habit
breakdown of addendum-planning.md that the planning editor needs."""
kinds: list[AdherenceKind] = Field(default_factory=list)
# --- Dashboard ----------------------------------------------------------------
class DashboardResponse(BaseModel):
date: dt.date
weight_kg: float | None = None
weight_measured_at: dt.datetime | None = None
trend_weight_kg: float | None = None
trend_delta_7d_kg: float | None = None
bmi: float | None = None
intake_kcal: float | None = None
budget_kcal: float | None = None
remaining_kcal: float | None = None
tdee_kcal: float | None = None
tdee_method: str | None = None
balance_kcal: float | None = None
cumulative_balance_30d_kcal: float | None = None
steps: int | None = None
active_kcal: float | None = None
distance_m: int | None = None
water_ml: int = 0
water_goal_ml: int | None = None
workouts_this_week: int = 0
goal: GoalRead | None = None
projection: ProjectionRead | None = None
weight_series: list[list[Any]] = Field(default_factory=list)
today: TodayResponse | None = None