"""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