Files
lifetrack/apps/api/app/modules/health/models.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

500 lines
20 KiB
Python

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