Files
lifetrack/apps/api/app/modules/vape/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

292 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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