"""Health module models (santé + nutrition + planning). Tables follow docs/design/datamodel-health-vape.md (§3-§4), the planning addendum (docs/design/addendum-planning.md) and CONVENTIONS C2/C8: - portable column types (suite runs on SQLite): non-native enums, JSONB_V; - SourceMixin + (user_id, source, external_id) / (user_id, content_hash) unique constraints on every connector-fed table; - FK to import_runs.id with ondelete="CASCADE" (central import rollback). """ import datetime as dt import enum from decimal import Decimal from typing import Any from sqlalchemy import ( Boolean, CheckConstraint, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint, text, ) from sqlalchemy import ( Enum as SAEnum, ) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.types import TypeDecorator from app.core.database import Base from app.core.mixins import JSONB_V, SourceMixin, TimestampMixin class UTCDateTime(TypeDecorator): """`timestamptz` that stays UTC-aware on SQLite too (CONVENTIONS C8). SQLite has no timezone-aware storage: SQLAlchemy drops the offset on write and returns naive datetimes on read, which would silently shift any input sent with a non-UTC offset and break `astimezone()` on read. This decorator normalizes to UTC on the way in and re-attaches UTC on the way out, so business code always manipulates aware UTC datetimes on both backends. """ impl = DateTime(timezone=True) cache_ok = True def process_bind_param( self, value: dt.datetime | None, dialect: Any ) -> dt.datetime | None: if value is None: return None if value.tzinfo is None: return value.replace(tzinfo=dt.UTC) return value.astimezone(dt.UTC) def process_result_value( self, value: dt.datetime | None, dialect: Any ) -> dt.datetime | None: if value is None: return None if value.tzinfo is None: return value.replace(tzinfo=dt.UTC) return value.astimezone(dt.UTC) def _enum(enum_cls: type[enum.Enum], name: str) -> SAEnum: """Portable enum column: VARCHAR + CHECK, persists the *values* (C8.1). `create_constraint=True` is explicit: SQLAlchemy defaults it to False, and without it the column is a bare VARCHAR with no CHECK — on PostgreSQL too. """ return SAEnum( enum_cls, name=name, native_enum=False, create_constraint=True, validate_strings=True, values_callable=lambda e: [m.value for m in e], ) class Sex(str, enum.Enum): MALE = "male" FEMALE = "female" OTHER = "other" # BMR formula: average of male/female offsets (§5.1) class ActivityLevel(str, enum.Enum): SEDENTARY = "sedentary" # factor 1.2 LIGHT = "light" # 1.375 MODERATE = "moderate" # 1.55 ACTIVE = "active" # 1.725 VERY_ACTIVE = "very_active" # 1.9 class MealType(str, enum.Enum): BREAKFAST = "breakfast" LUNCH = "lunch" DINNER = "dinner" SNACK = "snack" class GoalMode(str, enum.Enum): WEEKLY_RATE = "weekly_rate" # user sets kg/week -> budget derived TARGET_DATE = "target_date" # user sets the date -> rate derived MAINTAIN = "maintain" # budget = TDEE class GoalStatus(str, enum.Enum): ACTIVE = "active" COMPLETED = "completed" ABANDONED = "abandoned" class SportType(str, enum.Enum): TREADMILL_WALK = "treadmill_walk" TREADMILL_RUN = "treadmill_run" WALKING = "walking" RUNNING = "running" CYCLING = "cycling" SWIMMING = "swimming" STRENGTH = "strength" HIIT = "hiit" YOGA = "yoga" HIKING = "hiking" OTHER = "other" class ScheduleKind(str, enum.Enum): WEIGH_IN = "weigh_in" WORKOUT = "workout" FOOD_LOG = "food_log" class HealthProfile(TimestampMixin, Base): """One row per user (1-1): physiological parameters for BMR/TDEE.""" __tablename__ = "health_profiles" __table_args__ = ( UniqueConstraint("user_id", name="uq_health_profiles_user"), CheckConstraint( "height_cm > 0 AND height_cm < 300", name="ck_health_profiles_height" ), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) height_cm: Mapped[Decimal] = mapped_column(Numeric(4, 1)) sex: Mapped[Sex] = mapped_column(_enum(Sex, "sex")) birthdate: Mapped[dt.date] = mapped_column(Date) activity_level: Mapped[ActivityLevel] = mapped_column( _enum(ActivityLevel, "activity_level"), default=ActivityLevel.SEDENTARY ) timezone: Mapped[str] = mapped_column(String(64), default="Europe/Paris") water_goal_ml: Mapped[int | None] = mapped_column(Integer, default=2000) calorie_floor_kcal: Mapped[int | None] = mapped_column(Integer, default=None) class WeightEntry(TimestampMixin, SourceMixin, Base): """Weigh-ins; trend uses the first weigh-in of each local day (§3.2).""" __tablename__ = "weight_entries" __table_args__ = ( UniqueConstraint( "user_id", "source", "external_id", name="uq_weight_entries_external" ), UniqueConstraint("user_id", "content_hash", name="uq_weight_entries_hash"), UniqueConstraint( "user_id", "measured_at", "source", name="uq_weight_entries_user_ts_source" ), CheckConstraint( "weight_kg > 20 AND weight_kg < 400", name="ck_weight_entries_range" ), Index("ix_weight_entries_user_measured", "user_id", "measured_at"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), default=None ) measured_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) weight_kg: Mapped[Decimal] = mapped_column(Numeric(5, 2)) body_fat_pct: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) muscle_mass_kg: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), default=None) water_pct: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) note: Mapped[str | None] = mapped_column(String(255), default=None) raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) class BodyMeasurement(TimestampMixin, SourceMixin, Base): """One row = one measuring session; every site is optional (§3.3).""" __tablename__ = "body_measurements" __table_args__ = ( UniqueConstraint( "user_id", "source", "external_id", name="uq_body_measurements_external" ), UniqueConstraint("user_id", "content_hash", name="uq_body_measurements_hash"), Index("ix_body_measurements_user_measured", "user_id", "measured_at"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), default=None ) measured_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) neck_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) chest_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) waist_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) hips_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) biceps_left_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) biceps_right_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) thigh_left_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) thigh_right_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) calf_left_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) calf_right_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) note: Mapped[str | None] = mapped_column(String(255), default=None) class DailyActivity(TimestampMixin, SourceMixin, Base): """Daily activity aggregates, one row per (user, local day, source) (§3.4). Cross-source merge happens at read time, field by field, by source priority (calculations.ACTIVITY_SOURCE_PRIORITY) — rows from one source never overwrite another source's row. """ __tablename__ = "activity_daily" __table_args__ = ( UniqueConstraint( "user_id", "date", "source", name="uq_activity_daily_user_date_source" ), CheckConstraint("steps IS NULL OR steps >= 0", name="ck_activity_daily_steps"), Index("ix_activity_daily_user_date", "user_id", "date"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), default=None ) date: Mapped[dt.date] = mapped_column(Date) # local civil day (Europe/Paris) steps: Mapped[int | None] = mapped_column(Integer, default=None) active_kcal: Mapped[Decimal | None] = mapped_column(Numeric(7, 1), default=None) total_kcal: Mapped[Decimal | None] = mapped_column(Numeric(7, 1), default=None) distance_m: Mapped[int | None] = mapped_column(Integer, default=None) active_minutes: Mapped[int | None] = mapped_column(Integer, default=None) floors: Mapped[int | None] = mapped_column(Integer, default=None) raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) class Workout(TimestampMixin, SourceMixin, Base): """Sport sessions; cross-source 80 %-overlap dedup flags is_hidden (§3.6).""" __tablename__ = "workouts" __table_args__ = ( UniqueConstraint( "user_id", "source", "external_id", name="uq_workouts_external" ), UniqueConstraint("user_id", "content_hash", name="uq_workouts_hash"), CheckConstraint("ended_at > started_at", name="ck_workouts_duration"), Index("ix_workouts_user_started", "user_id", "started_at"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), default=None ) started_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) ended_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) sport_type: Mapped[SportType] = mapped_column(_enum(SportType, "sport_type")) sport_label: Mapped[str | None] = mapped_column(String(100), default=None) kcal: Mapped[Decimal | None] = mapped_column(Numeric(7, 1), default=None) distance_m: Mapped[int | None] = mapped_column(Integer, default=None) steps: Mapped[int | None] = mapped_column(Integer, default=None) avg_hr: Mapped[int | None] = mapped_column(Integer, default=None) max_hr: Mapped[int | None] = mapped_column(Integer, default=None) avg_speed_kmh: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) elevation_m: Mapped[int | None] = mapped_column(Integer, default=None) is_hidden: Mapped[bool] = mapped_column(Boolean, default=False) note: Mapped[str | None] = mapped_column(String(255), default=None) raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) @property def duration_s(self) -> int: """Session length in seconds (derived, never stored).""" return int((self.ended_at - self.started_at).total_seconds()) class Goal(TimestampMixin, Base): """Weight goals, historised; a single active goal per user (§3.7).""" __tablename__ = "goals" __table_args__ = ( Index( "uq_goals_user_active", "user_id", unique=True, sqlite_where=text("status = 'active'"), postgresql_where=text("status = 'active'"), ), CheckConstraint( "weekly_rate_kg IS NULL OR " "(weekly_rate_kg > -1.01 AND weekly_rate_kg <= 1.5)", name="ck_goals_rate_sane", ), CheckConstraint( "mode <> 'target_date' OR target_date IS NOT NULL", name="ck_goals_target_date", ), CheckConstraint( "mode <> 'weekly_rate' OR weekly_rate_kg IS NOT NULL", name="ck_goals_weekly_rate", ), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) mode: Mapped[GoalMode] = mapped_column(_enum(GoalMode, "goal_mode")) start_date: Mapped[dt.date] = mapped_column(Date) start_weight_kg: Mapped[Decimal] = mapped_column(Numeric(5, 2)) target_weight_kg: Mapped[Decimal] = mapped_column(Numeric(5, 2)) target_date: Mapped[dt.date | None] = mapped_column(Date, default=None) weekly_rate_kg: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), default=None) status: Mapped[GoalStatus] = mapped_column( _enum(GoalStatus, "goal_status"), default=GoalStatus.ACTIVE ) note: Mapped[str | None] = mapped_column(String(255), default=None) class FoodEntry(TimestampMixin, SourceMixin, Base): """Food journal; all nutritional values are ABSOLUTE for the eaten quantity, never per-100 g (§4.1).""" __tablename__ = "food_entries" __table_args__ = ( UniqueConstraint( "user_id", "source", "external_id", name="uq_food_entries_external" ), UniqueConstraint("user_id", "content_hash", name="uq_food_entries_hash"), CheckConstraint("kcal >= 0 AND quantity > 0", name="ck_food_entries_positive"), Index("ix_food_entries_user_eaten", "user_id", "eaten_at"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), default=None ) eaten_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) meal: Mapped[MealType] = mapped_column(_enum(MealType, "meal_type")) name: Mapped[str] = mapped_column(String(200)) brand: Mapped[str | None] = mapped_column(String(100), default=None) quantity: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal(1)) unit: Mapped[str] = mapped_column(String(20), default="g") kcal: Mapped[Decimal] = mapped_column(Numeric(7, 1)) protein_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) carbs_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) fiber_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) sugar_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) sat_fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) sodium_mg: Mapped[Decimal | None] = mapped_column(Numeric(8, 1), default=None) raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) class FoodFavorite(TimestampMixin, Base): """Favorite foods for quick entry; macros stored for default_quantity (same absolute convention as food_entries, §4.2).""" __tablename__ = "food_favorites" __table_args__ = ( UniqueConstraint( "user_id", "name", "brand", name="uq_food_favorites_user_name_brand" ), Index("ix_food_favorites_user_usage", "user_id", "use_count"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(200)) brand: Mapped[str | None] = mapped_column(String(100), default=None) default_quantity: Mapped[Decimal] = mapped_column(Numeric(8, 2)) unit: Mapped[str] = mapped_column(String(20), default="g") kcal: Mapped[Decimal] = mapped_column(Numeric(7, 1)) protein_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) carbs_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) fiber_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) default_meal: Mapped[MealType | None] = mapped_column( _enum(MealType, "meal_type"), default=None ) use_count: Mapped[int] = mapped_column(Integer, default=0) last_used_at: Mapped[dt.datetime | None] = mapped_column( UTCDateTime(), default=None ) class WaterEntry(TimestampMixin, SourceMixin, Base): """Hydration log (§4.3).""" __tablename__ = "water_entries" __table_args__ = ( UniqueConstraint( "user_id", "source", "external_id", name="uq_water_entries_external" ), UniqueConstraint("user_id", "content_hash", name="uq_water_entries_hash"), CheckConstraint( "volume_ml > 0 AND volume_ml <= 5000", name="ck_water_entries_volume" ), Index("ix_water_entries_user_drunk", "user_id", "drunk_at"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), default=None ) drunk_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) volume_ml: Mapped[int] = mapped_column(Integer) class FoodItem(TimestampMixin, Base): """Local food referential/cache (Open Food Facts / CIQUAL results). Deliberately NOT user-scoped: it caches public product data shared by all users (nutrition-sources.md §7.2), keyed by (source, source_id). All values are per 100 g. """ __tablename__ = "food_items" __table_args__ = ( UniqueConstraint("source", "source_id", name="uq_food_items_source_id"), Index("ix_food_items_name", "name"), ) id: Mapped[int] = mapped_column(primary_key=True) source: Mapped[str] = mapped_column(String(32)) # 'off' | 'ciqual' | 'custom' source_id: Mapped[str | None] = mapped_column(String(64), default=None) name: Mapped[str] = mapped_column(String(200)) brand: Mapped[str | None] = mapped_column(String(100), default=None) energy_kcal_100g: Mapped[Decimal | None] = mapped_column( Numeric(7, 1), default=None ) protein_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) carbs_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) sugar_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) fat_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) sat_fat_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) fiber_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) salt_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 2), default=None) serving_size_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) class TrackingSchedule(TimestampMixin, Base): """Weekly habit planning (addendum-planning.md): weigh-in / workout / food-log days. done/not-done is DERIVED from the data tables — no check-in table in v1.""" __tablename__ = "tracking_schedules" __table_args__ = ( UniqueConstraint("user_id", "kind", name="uq_tracking_schedules_user_kind"), ) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) kind: Mapped[ScheduleKind] = mapped_column(_enum(ScheduleKind, "schedule_kind")) # JSON list of ints, 0 = Monday … 6 = Sunday. weekdays: Mapped[list[int]] = mapped_column(JSONB_V, default=list) enabled: Mapped[bool] = mapped_column(Boolean, default=True)