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