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>
606 lines
18 KiB
Python
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
|