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>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""Vape module tables (datamodel-health-vape.md §6, money in euro cents).
|
||
|
||
Portable column types (CONVENTIONS C8): non-native enums, partial unique
|
||
indexes declared with BOTH sqlite_where and postgresql_where.
|
||
"""
|
||
|
||
import enum
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import (
|
||
CheckConstraint,
|
||
Date,
|
||
DateTime,
|
||
Enum,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
Numeric,
|
||
String,
|
||
UniqueConstraint,
|
||
text,
|
||
)
|
||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||
|
||
from app.core.database import Base
|
||
from app.core.mixins import SourceMixin, TimestampMixin
|
||
|
||
|
||
class ProductKind(str, enum.Enum):
|
||
COIL = "coil" # résistance
|
||
BASE = "base" # base PG/VG
|
||
BOOSTER = "booster" # booster de nicotine
|
||
AROMA = "aroma" # arôme concentré
|
||
HARDWARE = "hardware" # matériel (box, clearomiseur…) — hors coût/ml
|
||
POD = "pod" # cartouche pod
|
||
|
||
|
||
class SizeUnit(str, enum.Enum):
|
||
ML = "ml"
|
||
UNIT = "unit" # à l'unité (boîte de 5 résistances -> size_value=5)
|
||
G = "g"
|
||
|
||
|
||
class LiquidEntryKind(str, enum.Enum):
|
||
REFILL = "refill"
|
||
DAILY_TOTAL = "daily_total"
|
||
|
||
|
||
#: Product kinds that make up a DIY recipe (hardware/coil/pod are excluded).
|
||
LIQUID_KINDS = (ProductKind.BASE, ProductKind.BOOSTER, ProductKind.AROMA)
|
||
|
||
|
||
def _enum(enum_cls: type[enum.Enum], name: str) -> Enum:
|
||
"""Portable string enum persisting the *values* (lowercase), never native.
|
||
|
||
VARCHAR + CHECK on both engines: `create_constraint=True` is required, its
|
||
SQLAlchemy default is False (bare VARCHAR, no integrity check).
|
||
"""
|
||
return Enum(
|
||
enum_cls,
|
||
name=name,
|
||
native_enum=False,
|
||
create_constraint=True,
|
||
validate_strings=True,
|
||
values_callable=lambda e: [m.value for m in e],
|
||
)
|
||
|
||
|
||
class VapeSettings(TimestampMixin, Base):
|
||
"""Singleton per user: quit reference + defaults (§6.1)."""
|
||
|
||
__tablename__ = "vape_settings"
|
||
__table_args__ = (
|
||
UniqueConstraint("user_id", name="uq_vape_settings_user"),
|
||
CheckConstraint(
|
||
"cigs_per_pack > 0 AND cig_pack_price_cents >= 0"
|
||
" AND cigs_per_day_before >= 0",
|
||
name="ck_vape_settings_pack",
|
||
),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||
)
|
||
quit_date: Mapped[date] = mapped_column(Date)
|
||
cigs_per_day_before: Mapped[Decimal] = mapped_column(Numeric(4, 1))
|
||
cig_pack_price_cents: Mapped[int] = mapped_column(Integer)
|
||
cigs_per_pack: Mapped[int] = mapped_column(Integer, default=20)
|
||
default_nicotine_mg_ml: Mapped[Decimal] = mapped_column(Numeric(4, 1))
|
||
currency: Mapped[str] = mapped_column(String(3), default="EUR")
|
||
|
||
|
||
class Product(TimestampMixin, Base):
|
||
"""Vape catalog: coils, bases, boosters, aromas, hardware, pods (§6.2)."""
|
||
|
||
__tablename__ = "products"
|
||
__table_args__ = (
|
||
UniqueConstraint(
|
||
"user_id",
|
||
"kind",
|
||
"name",
|
||
"brand",
|
||
name="uq_products_user_kind_name_brand",
|
||
),
|
||
Index("ix_products_user_kind", "user_id", "kind"),
|
||
CheckConstraint(
|
||
"price_cents >= 0 AND size_value > 0", name="ck_products_price_size"
|
||
),
|
||
CheckConstraint(
|
||
"kind <> 'booster' OR nicotine_mg_ml IS NOT NULL",
|
||
name="ck_products_booster_nic",
|
||
),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||
)
|
||
kind: Mapped[ProductKind] = mapped_column(_enum(ProductKind, "product_kind"))
|
||
name: Mapped[str] = mapped_column(String(150))
|
||
brand: Mapped[str | None] = mapped_column(String(100), default=None)
|
||
price_cents: Mapped[int] = mapped_column(Integer) # package catalog price (cents)
|
||
size_value: Mapped[Decimal] = mapped_column(Numeric(8, 2))
|
||
size_unit: Mapped[SizeUnit] = mapped_column(_enum(SizeUnit, "size_unit"))
|
||
nicotine_mg_ml: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(4, 1), default=None
|
||
) # boosters only
|
||
vg_pct: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None)
|
||
ohm: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), default=None)
|
||
is_archived: Mapped[bool] = mapped_column(default=False)
|
||
note: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
|
||
@property
|
||
def unit_price_cents(self) -> Decimal:
|
||
"""Cents per ml/unit/g of the package (derived, never stored)."""
|
||
return Decimal(self.price_cents) / Decimal(self.size_value)
|
||
|
||
|
||
class Mix(TimestampMixin, Base):
|
||
"""DIY recipe; cost is always computed from catalog prices (§6.3)."""
|
||
|
||
__tablename__ = "mixes"
|
||
__table_args__ = (
|
||
Index(
|
||
"uq_mixes_user_active",
|
||
"user_id",
|
||
unique=True,
|
||
postgresql_where=text("is_active"),
|
||
sqlite_where=text("is_active"),
|
||
),
|
||
CheckConstraint("total_ml > 0", name="ck_mixes_total_ml"),
|
||
)
|
||
|
||
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(150))
|
||
total_ml: Mapped[Decimal] = mapped_column(Numeric(7, 1))
|
||
target_nicotine_mg_ml: Mapped[Decimal] = mapped_column(Numeric(4, 1))
|
||
is_active: Mapped[bool] = mapped_column(default=False)
|
||
is_archived: Mapped[bool] = mapped_column(default=False)
|
||
note: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
|
||
components: Mapped[list["MixComponent"]] = relationship(
|
||
back_populates="mix",
|
||
cascade="all, delete-orphan",
|
||
order_by="MixComponent.id",
|
||
lazy="selectin",
|
||
)
|
||
|
||
|
||
class MixComponent(Base):
|
||
"""One product line of a recipe; ownership goes through mixes.user_id."""
|
||
|
||
__tablename__ = "mix_components"
|
||
__table_args__ = (
|
||
UniqueConstraint("mix_id", "product_id", name="uq_mix_components_mix_product"),
|
||
CheckConstraint("quantity > 0", name="ck_mix_components_qty"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
mix_id: Mapped[int] = mapped_column(
|
||
ForeignKey("mixes.id", ondelete="CASCADE"), index=True
|
||
)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="RESTRICT")
|
||
)
|
||
quantity: Mapped[Decimal] = mapped_column(Numeric(7, 2)) # in product.size_unit
|
||
|
||
mix: Mapped[Mix] = relationship(back_populates="components")
|
||
product: Mapped[Product] = relationship(lazy="joined")
|
||
|
||
|
||
class LiquidEntry(TimestampMixin, SourceMixin, Base):
|
||
"""Liquid consumption journal: refills or direct daily totals (§6.4)."""
|
||
|
||
__tablename__ = "liquid_entries"
|
||
__table_args__ = (
|
||
Index("ix_liquid_entries_user_date", "user_id", "entry_date"),
|
||
Index(
|
||
"uq_liquid_entries_user_date_dailytotal",
|
||
"user_id",
|
||
"entry_date",
|
||
unique=True,
|
||
postgresql_where=text("kind = 'daily_total'"),
|
||
sqlite_where=text("kind = 'daily_total'"),
|
||
),
|
||
UniqueConstraint(
|
||
"user_id", "source", "external_id", name="uq_liquid_entries_external"
|
||
),
|
||
UniqueConstraint("user_id", "content_hash", name="uq_liquid_entries_hash"),
|
||
CheckConstraint("ml > 0 AND ml <= 100", name="ck_liquid_entries_ml"),
|
||
)
|
||
|
||
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
|
||
)
|
||
entry_date: Mapped[date] = mapped_column(Date) # local civil day
|
||
kind: Mapped[LiquidEntryKind] = mapped_column(
|
||
_enum(LiquidEntryKind, "liquid_entry_kind"),
|
||
default=LiquidEntryKind.REFILL,
|
||
)
|
||
ml: Mapped[Decimal] = mapped_column(Numeric(6, 2))
|
||
nicotine_mg_ml: Mapped[Decimal | None] = mapped_column(
|
||
Numeric(4, 1), default=None
|
||
) # override; fallback: mix target, then settings default
|
||
mix_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("mixes.id", ondelete="SET NULL"), default=None
|
||
)
|
||
note: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
|
||
|
||
class CoilChange(TimestampMixin, Base):
|
||
"""A new coil installed at `changed_at`; lifespan derived between rows (§6.5)."""
|
||
|
||
__tablename__ = "coil_changes"
|
||
__table_args__ = (Index("ix_coil_changes_user_changed", "user_id", "changed_at"),)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||
)
|
||
changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
product_id: Mapped[int | None] = mapped_column(
|
||
ForeignKey("products.id", ondelete="RESTRICT"), default=None
|
||
)
|
||
reason: Mapped[str | None] = mapped_column(String(50), default=None)
|
||
note: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
|
||
product: Mapped[Product | None] = relationship(lazy="joined")
|
||
|
||
|
||
class Purchase(TimestampMixin, Base):
|
||
"""Real vape spending, for real savings and real cost/ml (§6.6)."""
|
||
|
||
__tablename__ = "purchases"
|
||
__table_args__ = (
|
||
Index("ix_purchases_user_date", "user_id", "purchased_on"),
|
||
CheckConstraint("qty > 0", name="ck_purchases_qty"),
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(
|
||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||
)
|
||
purchased_on: Mapped[date] = mapped_column(Date)
|
||
product_id: Mapped[int] = mapped_column(
|
||
ForeignKey("products.id", ondelete="RESTRICT")
|
||
)
|
||
qty: Mapped[Decimal] = mapped_column(Numeric(6, 2), default=Decimal(1))
|
||
unit_price_cents: Mapped[int | None] = mapped_column(Integer, default=None)
|
||
note: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
|
||
product: Mapped[Product] = relationship(lazy="joined")
|
||
|
||
@property
|
||
def total_cents(self) -> Decimal:
|
||
"""qty × coalesce(unit_price_cents, product.price_cents), derived."""
|
||
unit = (
|
||
Decimal(self.unit_price_cents)
|
||
if self.unit_price_cents is not None
|
||
else Decimal(self.product.price_cents)
|
||
)
|
||
return Decimal(self.qty) * unit
|