Files
lifetrack/apps/api/app/modules/health/schemas.py
T
MeeJayandClaude Opus 5 fa3db7ff22 Fix 20+ defects found by real Docker/PostgreSQL deployment
Première exécution réelle de la stack (build des images, PostgreSQL 16,
parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm,
et 181 assertions rejouées après correction.

Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur
française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au
lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était
recalculé après le plancher calorique ; « dernière pesée » affichait deux
valeurs différentes selon l'endpoint ; objectif protéines absent.

Vape : durée de vie moyenne des résistances incluait la résistance en
cours ; archiver la recette active la laissait active ; coût théorique
inventé avant la date d'arrêt ; économies projetées dans le futur ;
€/ml arrondi à 2 décimales écrasait le modèle de coût DIY.

Finances : le sankey compensait crédits et débits non catégorisés ;
rows_total excluait les lignes filtrées, faussant l'arithmétique du
rapport d'import.

Socle : les erreurs HTTP du framework fuitaient en anglais dans
l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON
français ; fins de ligne normalisées en LF.

348 tests pytest (+7), ruff, tsc et vite build au vert.

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

606 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
protein_goal_g: float | 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