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>
500 lines
20 KiB
Python
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)
|