"""Vape business logic (datamodel-health-vape.md §6-§7, endpoints §8.4). Every function filters by `user_id`; user-facing messages are French. Money is handled in euro cents everywhere. """ from collections.abc import Iterable, Sequence from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal from typing import Any from zoneinfo import ZoneInfo from sqlalchemy import Select, func, select from sqlalchemy.orm import Session from app.core.errors import ConflictError, DomainValidationError, NotFoundError from app.core.pagination import Page, PageParams, paginate from app.core.timeutils import local_day, resolve_tz, utcnow from app.modules.vape import calculations as calc from app.modules.vape.models import ( LIQUID_KINDS, CoilChange, LiquidEntry, LiquidEntryKind, Mix, MixComponent, Product, ProductKind, Purchase, SizeUnit, VapeSettings, ) from app.modules.vape.schemas import ( CoilChangeCreate, CoilChangeCreated, CoilChangeRead, CoilChangeUpdate, LiquidEntryCreate, LiquidEntryRead, LiquidEntryUpdate, MilestoneRead, MixCalculatorRequest, MixCalculatorResult, MixComponentRead, MixCreate, MixRead, MixUpdate, ProductCreate, ProductRead, ProductUpdate, PurchaseCreate, PurchaseRead, PurchaseUpdate, SeriesModel, StatsResponse, VapeDashboard, VapeSettingsPut, ) DEFAULT_WINDOW_DAYS = 30 SHORT_WINDOW_DAYS = 7 DEFAULT_COST_MONTHS = 12 NICOTINE_TREND_WINDOW_DAYS = 30 # --------------------------------------------------------------------------- # Small helpers # --------------------------------------------------------------------------- def _round(value: float | Decimal | None, digits: int = 2) -> float | None: return None if value is None else round(float(value), digits) def _iso(day: date) -> str: return day.isoformat() def _as_utc(value: datetime) -> datetime: """SQLite gives naive datetimes back; stored values are always UTC.""" return value if value.tzinfo is not None else value.replace(tzinfo=UTC) def _apply_sort( stmt: Select, sort: str | None, allowed: dict[str, Any], default: str, tiebreak: Any, ) -> Select: """`?sort=field` / `?sort=-field` with an explicit whitelist (C2.8).""" raw = sort or default descending = raw.startswith("-") name = raw.lstrip("-") column = allowed.get(name) if column is None: raise DomainValidationError("Champ de tri non autorisé.", details={"sort": raw}) return stmt.order_by(column.desc() if descending else column.asc(), tiebreak) def _resolve_period( tz_name: str | None, from_: date | None, to: date | None, window: int ) -> tuple[ZoneInfo, date, date]: """(tz, from, to) with inclusive bounds; defaults to the last `window` days.""" tz = resolve_tz(tz_name) today = local_day(utcnow(), tz) end = to or today start = from_ or end - timedelta(days=window - 1) if start > end: raise DomainValidationError( "La date de début doit précéder la date de fin.", details={"from": _iso(start), "to": _iso(end)}, ) return tz, start, end # --------------------------------------------------------------------------- # Settings (§6.1) # --------------------------------------------------------------------------- def find_settings(db: Session, user_id: int) -> VapeSettings | None: return db.scalar(select(VapeSettings).where(VapeSettings.user_id == user_id)) def get_settings(db: Session, user_id: int) -> VapeSettings: settings = find_settings(db, user_id) if settings is None: raise NotFoundError( "Paramètres vape non configurés.", details={"setup_required": True} ) return settings def put_settings(db: Session, user_id: int, payload: VapeSettingsPut) -> VapeSettings: """Upsert of the per-user singleton (§8.4 PUT /vape/settings).""" settings = find_settings(db, user_id) if settings is None: settings = VapeSettings(user_id=user_id, **payload.model_dump()) db.add(settings) else: for field, value in payload.model_dump().items(): setattr(settings, field, value) db.commit() db.refresh(settings) return settings # --------------------------------------------------------------------------- # Products (§6.2) # --------------------------------------------------------------------------- def _product_read(product: Product) -> ProductRead: return ProductRead( id=product.id, kind=product.kind, name=product.name, brand=product.brand, price_cents=product.price_cents, size_value=product.size_value, size_unit=product.size_unit, nicotine_mg_ml=product.nicotine_mg_ml, vg_pct=product.vg_pct, ohm=product.ohm, is_archived=product.is_archived, note=product.note, unit_price_cents=float(product.unit_price_cents), ) def get_product(db: Session, user_id: int, product_id: int) -> Product: product = db.get(Product, product_id) if product is None or product.user_id != user_id: raise NotFoundError("Produit introuvable.") return product def list_products( db: Session, user_id: int, params: PageParams, kind: ProductKind | None = None, include_archived: bool = False, sort: str | None = None, ) -> Page[ProductRead]: stmt = select(Product).where(Product.user_id == user_id) if kind is not None: stmt = stmt.where(Product.kind == kind) if not include_archived: stmt = stmt.where(Product.is_archived.is_(False)) stmt = _apply_sort( stmt, sort, { "name": Product.name, "kind": Product.kind, "price_cents": Product.price_cents, }, "name", Product.id.desc(), ) rows, total = paginate(db, stmt, params) return Page( items=[_product_read(row) for row in rows], total=total, page=params.page, page_size=params.page_size, ) def _check_product_consistency( kind: ProductKind, nicotine_mg_ml: Decimal | None ) -> None: if kind is ProductKind.BOOSTER and nicotine_mg_ml is None: raise DomainValidationError( "Un booster doit indiquer son taux de nicotine (mg/ml)." ) def _check_product_unique( db: Session, user_id: int, kind: ProductKind, name: str, brand: str | None, exclude_id: int | None = None, ) -> None: stmt = select(Product.id).where( Product.user_id == user_id, Product.kind == kind, Product.name == name, Product.brand.is_(None) if brand is None else Product.brand == brand, ) if exclude_id is not None: stmt = stmt.where(Product.id != exclude_id) if db.scalar(stmt) is not None: raise ConflictError("Un produit identique existe déjà dans le catalogue.") def create_product(db: Session, user_id: int, payload: ProductCreate) -> ProductRead: _check_product_consistency(payload.kind, payload.nicotine_mg_ml) _check_product_unique(db, user_id, payload.kind, payload.name, payload.brand) product = Product(user_id=user_id, **payload.model_dump()) db.add(product) db.commit() db.refresh(product) return _product_read(product) def update_product( db: Session, user_id: int, product_id: int, payload: ProductUpdate ) -> ProductRead: product = get_product(db, user_id, product_id) changes = payload.model_dump(exclude_unset=True) kind = changes.get("kind", product.kind) nicotine = changes.get("nicotine_mg_ml", product.nicotine_mg_ml) _check_product_consistency(kind, nicotine) _check_product_unique( db, user_id, kind, changes.get("name", product.name), changes.get("brand", product.brand), exclude_id=product.id, ) for field, value in changes.items(): setattr(product, field, value) db.commit() db.refresh(product) return _product_read(product) def _product_references(db: Session, product_id: int) -> int: counts = ( db.scalar( select(func.count()) .select_from(MixComponent) .where(MixComponent.product_id == product_id) ) or 0 ) counts += ( db.scalar( select(func.count()) .select_from(CoilChange) .where(CoilChange.product_id == product_id) ) or 0 ) counts += ( db.scalar( select(func.count()) .select_from(Purchase) .where(Purchase.product_id == product_id) ) or 0 ) return counts def delete_product(db: Session, user_id: int, product_id: int) -> None: product = get_product(db, user_id, product_id) if _product_references(db, product_id): raise ConflictError( "Ce produit est utilisé par une recette, un achat ou un changement " "de résistance : archivez-le plutôt que de le supprimer.", details={"product_id": product_id}, ) db.delete(product) db.commit() # --------------------------------------------------------------------------- # Mixes (§6.3) # --------------------------------------------------------------------------- def _component_inputs(mix: Mix) -> list[calc.ComponentInput]: return [ calc.ComponentInput( quantity=component.quantity, price_cents=component.product.price_cents, size_value=component.product.size_value, nicotine_mg_ml=component.product.nicotine_mg_ml, vg_pct=component.product.vg_pct, ) for component in mix.components ] def _mix_read(mix: Mix) -> MixRead: inputs = _component_inputs(mix) cost_total = calc.mix_cost_total_cents(inputs) cost_per_ml = calc.mix_cost_per_ml_cents(inputs, mix.total_ml) check = calc.mix_nicotine_check_mg_ml(inputs, mix.total_ml) vg = calc.mix_vg_pct(inputs, mix.total_ml) return MixRead( id=mix.id, name=mix.name, total_ml=mix.total_ml, target_nicotine_mg_ml=mix.target_nicotine_mg_ml, is_active=mix.is_active, is_archived=mix.is_archived, note=mix.note, components=[ MixComponentRead( id=component.id, product_id=component.product_id, product_name=component.product.name, product_kind=component.product.kind, quantity=component.quantity, cost_cents=_round( calc.component_cost_cents( calc.ComponentInput( quantity=component.quantity, price_cents=component.product.price_cents, size_value=component.product.size_value, ) ) ) or 0.0, ) for component in mix.components ], cost_total_cents=_round(cost_total) or 0.0, cost_per_ml_cents=_round(cost_per_ml, 4), nicotine_check_mg_ml=_round(check, 3) or 0.0, vg_pct_mix=_round(vg), warning=calc.nicotine_mismatch_warning(mix.target_nicotine_mg_ml, check), ) def get_mix(db: Session, user_id: int, mix_id: int) -> Mix: mix = db.get(Mix, mix_id) if mix is None or mix.user_id != user_id: raise NotFoundError("Recette introuvable.") return mix def active_mix(db: Session, user_id: int) -> Mix | None: return db.scalar(select(Mix).where(Mix.user_id == user_id, Mix.is_active.is_(True))) def list_mixes( db: Session, user_id: int, params: PageParams, include_archived: bool = False, sort: str | None = None, ) -> Page[MixRead]: stmt = select(Mix).where(Mix.user_id == user_id) if not include_archived: stmt = stmt.where(Mix.is_archived.is_(False)) stmt = _apply_sort( stmt, sort, {"name": Mix.name, "created_at": Mix.created_at}, "-created_at", Mix.id.desc(), ) rows, total = paginate(db, stmt, params) return Page( items=[_mix_read(row) for row in rows], total=total, page=params.page, page_size=params.page_size, ) def _load_components( db: Session, user_id: int, lines: Sequence[Any] ) -> list[MixComponent]: seen: set[int] = set() components: list[MixComponent] = [] for line in lines: if line.product_id in seen: raise DomainValidationError( "Un même produit ne peut apparaître qu'une fois dans la recette.", details={"product_id": line.product_id}, ) seen.add(line.product_id) product = get_product(db, user_id, line.product_id) if product.kind not in LIQUID_KINDS: raise DomainValidationError( "Une recette n'accepte que des bases, boosters et arômes.", details={"product_id": product.id, "kind": product.kind.value}, ) components.append(MixComponent(product_id=product.id, quantity=line.quantity)) return components def create_mix(db: Session, user_id: int, payload: MixCreate) -> MixRead: mix = Mix( user_id=user_id, name=payload.name, total_ml=payload.total_ml, target_nicotine_mg_ml=payload.target_nicotine_mg_ml, note=payload.note, ) mix.components = _load_components(db, user_id, payload.components) db.add(mix) db.commit() db.refresh(mix) return _mix_read(mix) def update_mix(db: Session, user_id: int, mix_id: int, payload: MixUpdate) -> MixRead: mix = get_mix(db, user_id, mix_id) changes = payload.model_dump(exclude_unset=True) components = changes.pop("components", None) for field, value in changes.items(): setattr(mix, field, value) if mix.is_archived and mix.is_active: # An archived recipe is hidden from the lists: leaving it active would # keep it driving cost_per_ml while the UI shows no active recipe. mix.is_active = False if components is not None: # Full replacement of the recipe lines (§8.4). The old rows must be # deleted before the new ones are inserted, otherwise re-using the same # product violates uq_mix_components_mix_product during the flush. replacement = _load_components(db, user_id, payload.components or []) mix.components.clear() db.flush() mix.components = replacement db.commit() db.refresh(mix) return _mix_read(mix) def delete_mix(db: Session, user_id: int, mix_id: int) -> None: mix = get_mix(db, user_id, mix_id) db.delete(mix) # components cascade; liquid_entries.mix_id -> NULL db.commit() def activate_mix(db: Session, user_id: int, mix_id: int) -> MixRead: """Single active mix per user (partial unique index).""" mix = get_mix(db, user_id, mix_id) if mix.is_archived: raise ConflictError("Une recette archivée ne peut pas être activée.") for other in db.scalars( select(Mix).where( Mix.user_id == user_id, Mix.is_active.is_(True), Mix.id != mix.id ) ): other.is_active = False db.flush() # release the partial unique index before claiming it mix.is_active = True db.commit() db.refresh(mix) return _mix_read(mix) def mix_calculator( db: Session, user_id: int, payload: MixCalculatorRequest ) -> MixCalculatorResult: """Stateless recipe assistant (§6.3) — persists nothing.""" booster = get_product(db, user_id, payload.booster_product_id) base = get_product(db, user_id, payload.base_product_id) aroma = ( get_product(db, user_id, payload.aroma_product_id) if payload.aroma_product_id is not None else None ) if booster.nicotine_mg_ml is None or booster.nicotine_mg_ml <= 0: raise DomainValidationError( "Le booster sélectionné doit avoir un taux de nicotine supérieur à 0." ) try: quantities = calc.recipe_quantities( payload.total_ml, payload.target_nicotine_mg_ml, booster.nicotine_mg_ml, payload.aroma_pct, ) except ValueError as exc: raise DomainValidationError( "Recette impossible : le volume de base serait nul ou négatif, " "réduisez le taux de nicotine ou le dosage d'arôme." ) from exc components = [ calc.ComponentInput( quantity=quantities.booster_ml, price_cents=booster.price_cents, size_value=booster.size_value, nicotine_mg_ml=booster.nicotine_mg_ml, ), calc.ComponentInput( quantity=quantities.base_ml, price_cents=base.price_cents, size_value=base.size_value, ), ] if aroma is not None and quantities.aroma_ml > 0: components.append( calc.ComponentInput( quantity=quantities.aroma_ml, price_cents=aroma.price_cents, size_value=aroma.size_value, ) ) cost_total = calc.mix_cost_total_cents(components) cost_per_ml = calc.mix_cost_per_ml_cents(components, payload.total_ml) check = calc.mix_nicotine_check_mg_ml(components, payload.total_ml) return MixCalculatorResult( total_ml=payload.total_ml, target_nicotine_mg_ml=payload.target_nicotine_mg_ml, booster_ml=_round(quantities.booster_ml) or 0.0, aroma_ml=_round(quantities.aroma_ml) or 0.0, base_ml=_round(quantities.base_ml) or 0.0, nicotine_check_mg_ml=_round(check, 3) or 0.0, cost_total_cents=_round(cost_total) or 0.0, cost_per_ml_cents=_round(cost_per_ml, 4) or 0.0, ) # --------------------------------------------------------------------------- # Liquid entries (§6.4) # --------------------------------------------------------------------------- def _entries_between( db: Session, user_id: int, start: date, end: date ) -> list[LiquidEntry]: return list( db.scalars( select(LiquidEntry) .where( LiquidEntry.user_id == user_id, LiquidEntry.entry_date >= start, LiquidEntry.entry_date <= end, ) .order_by(LiquidEntry.entry_date, LiquidEntry.id) ) ) def _group_by_day(entries: Iterable[LiquidEntry]) -> dict[date, list[LiquidEntry]]: grouped: dict[date, list[LiquidEntry]] = {} for entry in entries: grouped.setdefault(entry.entry_date, []).append(entry) return grouped def _counting_entries(entries: Sequence[LiquidEntry]) -> list[LiquidEntry]: """The entries that actually count for a day: daily_total wins over refills.""" totals = [e for e in entries if e.kind is LiquidEntryKind.DAILY_TOTAL] if totals: return totals[:1] return [e for e in entries if e.kind is LiquidEntryKind.REFILL] def _daily_ml_map( db: Session, user_id: int, start: date, end: date ) -> dict[date, Decimal]: """Tracked days only: {day: effective ml} (§6.4 daily_total override).""" grouped = _group_by_day(_entries_between(db, user_id, start, end)) out: dict[date, Decimal] = {} for day, entries in grouped.items(): value = calc.effective_daily_ml((e.kind.value, e.ml) for e in entries) if value is not None: out[day] = value return out def _effective_nicotine( entry: LiquidEntry, mix_targets: dict[int, Decimal], settings: VapeSettings | None, ) -> Decimal | None: """entry override -> mix target -> settings default (§6.4).""" if entry.nicotine_mg_ml is not None: return entry.nicotine_mg_ml if entry.mix_id is not None and entry.mix_id in mix_targets: return mix_targets[entry.mix_id] if settings is not None: return settings.default_nicotine_mg_ml return None def _mix_targets(db: Session, user_id: int) -> dict[int, Decimal]: rows = db.execute( select(Mix.id, Mix.target_nicotine_mg_ml).where(Mix.user_id == user_id) ).all() return {row[0]: row[1] for row in rows} def _daily_nicotine_map( db: Session, user_id: int, start: date, end: date, settings: VapeSettings | None, ) -> dict[date, float]: grouped = _group_by_day(_entries_between(db, user_id, start, end)) targets = _mix_targets(db, user_id) out: dict[date, float] = {} for day, entries in grouped.items(): counting = _counting_entries(entries) value = calc.nicotine_mg_for_day( (e.ml, _effective_nicotine(e, targets, settings)) for e in counting ) if value is not None: out[day] = value return out def _liquid_read( entry: LiquidEntry, mix_targets: dict[int, Decimal], settings: VapeSettings | None, ) -> LiquidEntryRead: effective = _effective_nicotine(entry, mix_targets, settings) return LiquidEntryRead( id=entry.id, entry_date=entry.entry_date, kind=entry.kind, ml=entry.ml, nicotine_mg_ml=entry.nicotine_mg_ml, nicotine_effective_mg_ml=_round(effective, 1), nicotine_mg=_round(entry.ml * effective) if effective is not None else None, mix_id=entry.mix_id, source=entry.source, note=entry.note, ) def get_liquid(db: Session, user_id: int, entry_id: int) -> LiquidEntry: entry = db.get(LiquidEntry, entry_id) if entry is None or entry.user_id != user_id: raise NotFoundError("Saisie de consommation introuvable.") return entry def list_liquids( db: Session, user_id: int, params: PageParams, from_: date | None = None, to: date | None = None, kind: LiquidEntryKind | None = None, sort: str | None = None, ) -> Page[LiquidEntryRead]: stmt = select(LiquidEntry).where(LiquidEntry.user_id == user_id) if from_ is not None: stmt = stmt.where(LiquidEntry.entry_date >= from_) if to is not None: stmt = stmt.where(LiquidEntry.entry_date <= to) if kind is not None: stmt = stmt.where(LiquidEntry.kind == kind) stmt = _apply_sort( stmt, sort, {"entry_date": LiquidEntry.entry_date, "ml": LiquidEntry.ml}, "-entry_date", LiquidEntry.id.desc(), ) rows, total = paginate(db, stmt, params) settings = find_settings(db, user_id) targets = _mix_targets(db, user_id) return Page( items=[_liquid_read(row, targets, settings) for row in rows], total=total, page=params.page, page_size=params.page_size, ) def _assert_single_daily_total( db: Session, user_id: int, day: date, exclude_id: int | None = None ) -> None: stmt = select(LiquidEntry.id).where( LiquidEntry.user_id == user_id, LiquidEntry.entry_date == day, LiquidEntry.kind == LiquidEntryKind.DAILY_TOTAL, ) if exclude_id is not None: stmt = stmt.where(LiquidEntry.id != exclude_id) if db.scalar(stmt) is not None: raise ConflictError( "Un total quotidien existe déjà pour ce jour : modifiez-le.", details={"entry_date": _iso(day)}, ) def create_liquid( db: Session, user_id: int, payload: LiquidEntryCreate ) -> LiquidEntryRead: if payload.kind is LiquidEntryKind.DAILY_TOTAL: _assert_single_daily_total(db, user_id, payload.entry_date) mix_id = payload.mix_id if mix_id is not None: get_mix(db, user_id, mix_id) else: current = active_mix(db, user_id) mix_id = current.id if current is not None else None entry = LiquidEntry( user_id=user_id, entry_date=payload.entry_date, kind=payload.kind, ml=payload.ml, nicotine_mg_ml=payload.nicotine_mg_ml, mix_id=mix_id, note=payload.note, source="manual", ) db.add(entry) db.commit() db.refresh(entry) return _liquid_read(entry, _mix_targets(db, user_id), find_settings(db, user_id)) def update_liquid( db: Session, user_id: int, entry_id: int, payload: LiquidEntryUpdate ) -> LiquidEntryRead: entry = get_liquid(db, user_id, entry_id) changes = payload.model_dump(exclude_unset=True) kind = changes.get("kind", entry.kind) day = changes.get("entry_date", entry.entry_date) if kind is LiquidEntryKind.DAILY_TOTAL: _assert_single_daily_total(db, user_id, day, exclude_id=entry.id) if changes.get("mix_id") is not None: get_mix(db, user_id, changes["mix_id"]) for field, value in changes.items(): setattr(entry, field, value) db.commit() db.refresh(entry) return _liquid_read(entry, _mix_targets(db, user_id), find_settings(db, user_id)) def delete_liquid(db: Session, user_id: int, entry_id: int) -> None: entry = get_liquid(db, user_id, entry_id) db.delete(entry) db.commit() # --------------------------------------------------------------------------- # Cost model (§7.1) and coils (§7.3) # --------------------------------------------------------------------------- def _liquid_purchase_pairs( db: Session, user_id: int, until: date, window_days: int ) -> list[tuple[Decimal, Decimal]]: """(total_cents, ml) of the liquid purchases of the fallback window.""" since = until - timedelta(days=window_days) rows = db.scalars( select(Purchase) .join(Product, Purchase.product_id == Product.id) .where( Purchase.user_id == user_id, Purchase.purchased_on >= since, Purchase.purchased_on <= until, Product.kind.in_(LIQUID_KINDS), Product.size_unit == SizeUnit.ML, ) ) return [(row.total_cents, row.qty * row.product.size_value) for row in rows] def cost_per_ml_cents(db: Session, user_id: int, today: date) -> Decimal | None: """Active mix cost/ml, else weighted purchases fallback, else None (§7.1).""" mix = active_mix(db, user_id) if mix is not None and mix.components: value = calc.mix_cost_per_ml_cents(_component_inputs(mix), mix.total_ml) if value is not None: return value return calc.fallback_cost_per_ml_cents( _liquid_purchase_pairs(db, user_id, today, calc.PURCHASE_FALLBACK_WINDOW_DAYS) ) @dataclass class CoilContext: changes: list[CoilChange] intervals_days: list[float] avg_lifespan_days: float is_default: bool unit_price_cents: float | None cost_per_day_cents: float current_age_days: float | None current_product_id: int | None def _coil_changes(db: Session, user_id: int) -> list[CoilChange]: return list( db.scalars( select(CoilChange) .where(CoilChange.user_id == user_id) .order_by(CoilChange.changed_at, CoilChange.id) ) ) def coil_context(db: Session, user_id: int, now_utc: datetime) -> CoilContext: changes = _coil_changes(db, user_id) intervals = calc.coil_intervals_days([_as_utc(c.changed_at) for c in changes]) avg_days, is_default = calc.avg_coil_lifespan_days(intervals) unit_price: float | None = None for change in reversed(changes): if change.product is not None: unit_price = float(change.product.unit_price_cents) break current = changes[-1] if changes else None return CoilContext( changes=changes, intervals_days=intervals, avg_lifespan_days=avg_days, is_default=is_default, unit_price_cents=unit_price, cost_per_day_cents=calc.coil_cost_per_day_cents(unit_price, avg_days), current_age_days=( calc.coil_age_days(_as_utc(current.changed_at), now_utc) if current is not None else None ), current_product_id=current.product_id if current is not None else None, ) def _coil_derived( changes: Sequence[CoilChange], daily_ml: dict[date, Decimal], tz: ZoneInfo, now_utc: datetime, ) -> dict[int, tuple[float, float | None, bool]]: """{coil_change_id: (lifespan_days, ml_through, is_current)}.""" out: dict[int, tuple[float, float | None, bool]] = {} for index, change in enumerate(changes): is_current = index == len(changes) - 1 started_at = _as_utc(change.changed_at) end_dt = now_utc if is_current else _as_utc(changes[index + 1].changed_at) lifespan = (end_dt - started_at).total_seconds() / calc.SECONDS_PER_DAY start_day = local_day(started_at, tz) end_day = local_day(end_dt, tz) # [start_day, end_day) so two consecutive coils never share a day; # the coil in use also counts the current (unfinished) day. last_day = end_day + timedelta(days=1) if is_current else end_day ml = calc.sum_ml_between(daily_ml, start_day, last_day) out[change.id] = (lifespan, float(ml) if ml is not None else None, is_current) return out def _coil_read( change: CoilChange, derived: dict[int, tuple[float, float | None, bool]] ) -> CoilChangeRead: lifespan, ml_through, is_current = derived.get(change.id, (0.0, None, False)) return CoilChangeRead( id=change.id, changed_at=_as_utc(change.changed_at), product_id=change.product_id, product_name=change.product.name if change.product is not None else None, reason=change.reason, note=change.note, is_current=is_current, lifespan_days=_round(lifespan), ml_through=_round(ml_through), ) def get_coil(db: Session, user_id: int, coil_id: int) -> CoilChange: change = db.get(CoilChange, coil_id) if change is None or change.user_id != user_id: raise NotFoundError("Changement de résistance introuvable.") return change def _coil_derived_all( db: Session, user_id: int, tz_name: str | None ) -> tuple[dict[int, tuple[float, float | None, bool]], list[CoilChange]]: tz = resolve_tz(tz_name) now = utcnow() changes = _coil_changes(db, user_id) if not changes: return {}, changes start_day = local_day(_as_utc(changes[0].changed_at), tz) daily_ml = _daily_ml_map(db, user_id, start_day, local_day(now, tz)) return _coil_derived(changes, daily_ml, tz, now), changes def list_coils( db: Session, user_id: int, params: PageParams, from_: date | None = None, to: date | None = None, sort: str | None = None, tz_name: str | None = None, ) -> Page[CoilChangeRead]: derived, _ = _coil_derived_all(db, user_id, tz_name) stmt = select(CoilChange).where(CoilChange.user_id == user_id) if from_ is not None: stmt = stmt.where(CoilChange.changed_at >= _start_of_day(from_, tz_name)) if to is not None: stmt = stmt.where(CoilChange.changed_at < _start_of_day(to, tz_name, offset=1)) stmt = _apply_sort( stmt, sort, {"changed_at": CoilChange.changed_at}, "-changed_at", CoilChange.id.desc(), ) rows, total = paginate(db, stmt, params) return Page( items=[_coil_read(row, derived) for row in rows], total=total, page=params.page, page_size=params.page_size, ) def _start_of_day(day: date, tz_name: str | None, offset: int = 0) -> datetime: """Local midnight of `day` (+offset days) expressed in UTC, for filters.""" tz = resolve_tz(tz_name) return datetime.combine( day + timedelta(days=offset), datetime.min.time(), tzinfo=tz ).astimezone(UTC) def _created_coil_read( db: Session, user_id: int, change: CoilChange, tz_name: str | None ) -> CoilChangeCreated: derived, changes = _coil_derived_all(db, user_id, tz_name) previous: float | None = None for index, item in enumerate(changes): if item.id == change.id and index > 0: previous_change = changes[index - 1] previous = _round( ( _as_utc(item.changed_at) - _as_utc(previous_change.changed_at) ).total_seconds() / calc.SECONDS_PER_DAY ) base = _coil_read(change, derived) return CoilChangeCreated(**base.model_dump(), previous_lifespan_days=previous) def create_coil( db: Session, user_id: int, payload: CoilChangeCreate, tz_name: str | None = None, ) -> CoilChangeCreated: """CRUD create and the one-click « Résistance changée » action (§12.6).""" if payload.product_id is not None: product = get_product(db, user_id, payload.product_id) if product.kind not in (ProductKind.COIL, ProductKind.POD): raise DomainValidationError( "Le produit posé doit être une résistance ou un pod." ) change = CoilChange( user_id=user_id, # always persisted in UTC, whatever offset the client sent changed_at=_as_utc(payload.changed_at).astimezone(UTC) if payload.changed_at is not None else utcnow(), product_id=payload.product_id, reason=payload.reason, note=payload.note, ) db.add(change) db.commit() db.refresh(change) return _created_coil_read(db, user_id, change, tz_name) def update_coil( db: Session, user_id: int, coil_id: int, payload: CoilChangeUpdate, tz_name: str | None = None, ) -> CoilChangeRead: change = get_coil(db, user_id, coil_id) changes = payload.model_dump(exclude_unset=True) if changes.get("product_id") is not None: get_product(db, user_id, changes["product_id"]) if changes.get("changed_at") is not None: changes["changed_at"] = _as_utc(changes["changed_at"]).astimezone(UTC) for field, value in changes.items(): setattr(change, field, value) db.commit() db.refresh(change) derived, _ = _coil_derived_all(db, user_id, tz_name) return _coil_read(change, derived) def delete_coil(db: Session, user_id: int, coil_id: int) -> None: change = get_coil(db, user_id, coil_id) db.delete(change) db.commit() def coil_stats(db: Session, user_id: int, tz_name: str | None = None) -> StatsResponse: """Lifespan history + amortization metrics (§7.3).""" tz = resolve_tz(tz_name) now = utcnow() today = local_day(now, tz) ctx = coil_context(db, user_id, now) derived, changes = _coil_derived_all(db, user_id, tz_name) points: list[tuple[str, float | None]] = [] ml_points: list[tuple[str, float | None]] = [] start = local_day(_as_utc(changes[0].changed_at), tz) if changes else today for change in changes: lifespan, ml_through, _is_current = derived[change.id] day = local_day(_as_utc(change.changed_at), tz) points.append((_iso(day), _round(lifespan))) ml_points.append((_iso(day), _round(ml_through))) # §7.3: the average volume is computed on *completed* cycles only (the coil # in use has been vaped for a few days at most and would drag the mean down), # and like avg_lifespan_days it looks at the last COIL_AVG_LAST_N of them. completed_ml: list[float] = [] for change in changes: _lifespan, ml_through, is_current = derived[change.id] if not is_current and ml_through is not None: completed_ml.append(ml_through) ml_values = completed_ml[-calc.COIL_AVG_LAST_N :] return StatsResponse( from_=start, to=today, unit="days", series=[ SeriesModel(name="lifespan_days", type="bar", points=points), SeriesModel(name="ml_through", type="line", points=ml_points), ], meta={ "avg_lifespan_days": _round(ctx.avg_lifespan_days), "avg_lifespan_is_default": ctx.is_default, "avg_ml_through_coil": ( _round(sum(ml_values) / len(ml_values)) if ml_values else None ), "current_coil_age_days": _round(ctx.current_age_days), "current_coil_product_id": ctx.current_product_id, "coil_unit_price_cents": _round(ctx.unit_price_cents), "coil_cost_per_day_cents": _round(ctx.cost_per_day_cents), "changes_count": len(changes), }, ) # --------------------------------------------------------------------------- # Purchases (§6.6) # --------------------------------------------------------------------------- def _purchase_read(purchase: Purchase) -> PurchaseRead: return PurchaseRead( id=purchase.id, purchased_on=purchase.purchased_on, product_id=purchase.product_id, product_name=purchase.product.name, qty=purchase.qty, unit_price_cents=purchase.unit_price_cents, total_cents=_round(purchase.total_cents) or 0.0, note=purchase.note, ) def get_purchase(db: Session, user_id: int, purchase_id: int) -> Purchase: purchase = db.get(Purchase, purchase_id) if purchase is None or purchase.user_id != user_id: raise NotFoundError("Achat introuvable.") return purchase def list_purchases( db: Session, user_id: int, params: PageParams, from_: date | None = None, to: date | None = None, product_id: int | None = None, sort: str | None = None, ) -> Page[PurchaseRead]: stmt = select(Purchase).where(Purchase.user_id == user_id) if from_ is not None: stmt = stmt.where(Purchase.purchased_on >= from_) if to is not None: stmt = stmt.where(Purchase.purchased_on <= to) if product_id is not None: stmt = stmt.where(Purchase.product_id == product_id) stmt = _apply_sort( stmt, sort, {"purchased_on": Purchase.purchased_on, "qty": Purchase.qty}, "-purchased_on", Purchase.id.desc(), ) rows, total = paginate(db, stmt, params) return Page( items=[_purchase_read(row) for row in rows], total=total, page=params.page, page_size=params.page_size, ) def create_purchase(db: Session, user_id: int, payload: PurchaseCreate) -> PurchaseRead: get_product(db, user_id, payload.product_id) purchase = Purchase(user_id=user_id, **payload.model_dump()) db.add(purchase) db.commit() db.refresh(purchase) return _purchase_read(purchase) def update_purchase( db: Session, user_id: int, purchase_id: int, payload: PurchaseUpdate ) -> PurchaseRead: purchase = get_purchase(db, user_id, purchase_id) changes = payload.model_dump(exclude_unset=True) if "product_id" in changes and changes["product_id"] is not None: get_product(db, user_id, changes["product_id"]) for field, value in changes.items(): setattr(purchase, field, value) db.commit() db.refresh(purchase) return _purchase_read(purchase) def delete_purchase(db: Session, user_id: int, purchase_id: int) -> None: purchase = get_purchase(db, user_id, purchase_id) db.delete(purchase) db.commit() def _spend_by_date( db: Session, user_id: int, start: date, end: date ) -> dict[date, float]: out: dict[date, float] = {} rows = db.scalars( select(Purchase).where( Purchase.user_id == user_id, Purchase.purchased_on >= start, Purchase.purchased_on <= end, ) ) for row in rows: out[row.purchased_on] = out.get(row.purchased_on, 0.0) + float(row.total_cents) return out def _has_purchases(db: Session, user_id: int) -> bool: return ( db.scalar( select(func.count()) .select_from(Purchase) .where(Purchase.user_id == user_id) ) or 0 ) > 0 # --------------------------------------------------------------------------- # Stats (§8.1 chart-ready payloads) # --------------------------------------------------------------------------- def consumption_stats( db: Session, user_id: int, from_: date | None = None, to: date | None = None, tz_name: str | None = None, ) -> StatsResponse: """ml/day + 7-day moving average (§7.2).""" tz, start, end = _resolve_period(tz_name, from_, to, DEFAULT_WINDOW_DAYS) today = local_day(utcnow(), tz) daily = _daily_ml_map(db, user_id, start, end) days = list(calc.date_range(start, end)) values: list[float | None] = [ float(daily[day]) if day in daily else None for day in days ] ma7 = calc.moving_average(values, SHORT_WINDOW_DAYS) window7 = _daily_ml_map( db, user_id, today - timedelta(days=SHORT_WINDOW_DAYS - 1), today ) window30 = _daily_ml_map( db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today ) tracked = [daily.get(day) for day in days] return StatsResponse( from_=start, to=end, unit="ml", series=[ SeriesModel( name="ml", type="line", points=[ (_iso(d), _round(v)) for d, v in zip(days, values, strict=True) ], ), SeriesModel( name="ml_ma7", type="line", points=[(_iso(d), _round(v)) for d, v in zip(days, ma7, strict=True)], ), ], meta={ "ml_per_day_7": _round(calc.mean_ml_per_day(window7.values())), "ml_per_day_30": _round(calc.mean_ml_per_day(window30.values())), "ml_per_day_period": _round(calc.mean_ml_per_day(tracked)), "tracked_days_ratio": _round(calc.tracked_days_ratio(tracked), 3), "tracked_days": sum(1 for v in tracked if v is not None), "total_ml": _round(sum(float(v) for v in daily.values())), }, ) def nicotine_stats( db: Session, user_id: int, from_: date | None = None, to: date | None = None, tz_name: str | None = None, ) -> StatsResponse: """mg/day + moving average, 30-day trend and cigarette equivalent (§7.6).""" _tz, start, end = _resolve_period(tz_name, from_, to, DEFAULT_WINDOW_DAYS) settings = find_settings(db, user_id) daily = _daily_nicotine_map(db, user_id, start, end, settings) days = list(calc.date_range(start, end)) values: list[float | None] = [daily.get(day) for day in days] ma7 = calc.moving_average(values, SHORT_WINDOW_DAYS) trend_start = max(start, end - timedelta(days=NICOTINE_TREND_WINDOW_DAYS - 1)) trend_points = [ (day, daily[day]) for day in calc.date_range(trend_start, end) if day in daily ] slope = calc.regression_slope(trend_points) tracked_values = [v for v in values if v is not None] mean_mg = sum(tracked_values) / len(tracked_values) if tracked_values else None return StatsResponse( from_=start, to=end, unit="mg", series=[ SeriesModel( name="nicotine_mg", type="bar", points=[ (_iso(d), _round(v)) for d, v in zip(days, values, strict=True) ], ), SeriesModel( name="nicotine_mg_ma7", type="line", points=[(_iso(d), _round(v)) for d, v in zip(days, ma7, strict=True)], ), ], meta={ "nicotine_mg_per_day": _round(mean_mg), "slope_30d_mg_per_day": _round(slope, 4), "trend_status": calc.trend_status(slope), "cig_equivalent_per_day": ( _round(calc.cig_equivalent(mean_mg)) if mean_mg is not None else None ), "nicotine_mg_per_cig": calc.NICOTINE_MG_PER_CIG, }, ) def _is_vaping_day(day: date, today: date, settings: VapeSettings | None) -> bool: """Whether an *untracked* day may be mean-imputed (§7.5). Only days the user could actually have vaped count: from the quit date (when it is known) up to today — never the future. """ if day > today: return False return settings is None or day >= settings.quit_date def _month_start(day: date) -> date: return day.replace(day=1) def _add_month(day: date) -> date: return (day.replace(day=28) + timedelta(days=4)).replace(day=1) def cost_stats( db: Session, user_id: int, from_: date | None = None, to: date | None = None, tz_name: str | None = None, ) -> StatsResponse: """Monthly theoretical cost vs real spending (§7.4).""" tz = resolve_tz(tz_name) today = local_day(utcnow(), tz) end = to or today if from_ is None: start = _month_start(end) for _ in range(DEFAULT_COST_MONTHS - 1): start = _month_start(start - timedelta(days=1)) else: start = from_ if start > end: raise DomainValidationError("La date de début doit précéder la date de fin.") daily = _daily_ml_map(db, user_id, start, end) cpm = cost_per_ml_cents(db, user_id, today) ctx = coil_context(db, user_id, utcnow()) window30 = _daily_ml_map( db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today ) imputed = calc.mean_ml_per_day(window30.values()) spend = _spend_by_date(db, user_id, start, end) settings = find_settings(db, user_id) theoretical: dict[date, float] = {} for day in calc.date_range(start, end): if daily.get(day) is None and not _is_vaping_day(day, today, settings): # Mean-imputation fills tracking *gaps* (§7.5); it must not invent a # vape cost before the quit date or in the future. continue month = _month_start(day) theoretical[month] = theoretical.get( month, 0.0 ) + calc.theoretical_vape_cost_cents( daily.get(day), imputed, cpm, ctx.cost_per_day_cents ) real: dict[date, float] = {} for day, amount in spend.items(): month = _month_start(day) real[month] = real.get(month, 0.0) + amount months: list[date] = [] cursor = _month_start(start) while cursor <= end: months.append(cursor) cursor = _add_month(cursor) window_days = (end - start).days + 1 ml_per_day = calc.mean_ml_per_day( [daily.get(day) for day in calc.date_range(start, end)] ) vape_cpd = calc.vape_cost_per_day_cents(ml_per_day, cpm, ctx.cost_per_day_cents) return StatsResponse( from_=start, to=end, unit="cents", series=[ SeriesModel( name="theoretical_cost", type="bar", points=[(_iso(m), _round(theoretical.get(m, 0.0))) for m in months], ), SeriesModel( name="real_spend", type="bar", points=[(_iso(m), _round(real.get(m, 0.0))) for m in months], ), ], meta={ "cost_per_ml_cents": _round(cpm, 4), "cost_per_ml_source": ( "active_mix" if active_mix(db, user_id) is not None else "purchases" ), "vape_cost_per_day_cents": _round(vape_cpd), "coil_cost_per_day_cents": _round(ctx.cost_per_day_cents), "coil_avg_lifespan_days": _round(ctx.avg_lifespan_days), "real_cost_per_day_cents": _round( calc.real_cost_per_day_cents(sum(spend.values()), window_days) ), "total_real_spend_cents": _round(sum(spend.values())), }, ) def savings_stats( db: Session, user_id: int, from_: date | None = None, to: date | None = None, tz_name: str | None = None, ) -> StatsResponse: """Cumulative savings since quit_date: theoretical vs real (§7.5).""" settings = get_settings(db, user_id) tz = resolve_tz(tz_name) today = local_day(utcnow(), tz) # Cumulative savings stop today: a `to` in the future would project imputed # days and inflate the headline « économies » (§7.5). end = min(to, today) if to is not None else today quit_date = settings.quit_date start = from_ or quit_date daily = _daily_ml_map(db, user_id, quit_date, end) window30 = _daily_ml_map( db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today ) imputed = calc.mean_ml_per_day(window30.values()) cpm = cost_per_ml_cents(db, user_id, today) ctx = coil_context(db, user_id, utcnow()) cig_cpd = calc.cig_cost_per_day_cents( settings.cigs_per_day_before, settings.cigs_per_pack, settings.cig_pack_price_cents, ) theoretical = calc.cumulative_savings_theoretical( quit_date, end, daily, imputed, cpm, ctx.cost_per_day_cents, cig_cpd ) real = calc.cumulative_savings_real( quit_date, end, _spend_by_date(db, user_id, quit_date, end), cig_cpd ) elapsed = calc.days_since_quit(quit_date, today) avoided = calc.cigarettes_avoided(elapsed, settings.cigs_per_day_before) ml_per_day = calc.mean_ml_per_day(window30.values()) vape_cpd = calc.vape_cost_per_day_cents(ml_per_day, cpm, ctx.cost_per_day_cents) return StatsResponse( from_=max(start, quit_date), to=end, unit="cents", series=[ SeriesModel( name="savings_theoretical", type="line", points=[(_iso(d), _round(v)) for d, v in theoretical if d >= start], ), SeriesModel( name="savings_real", type="line", points=[(_iso(d), _round(v)) for d, v in real if d >= start], ), ], meta={ "quit_date": _iso(quit_date), "days_since_quit": elapsed, "cig_cost_per_day_cents": _round(cig_cpd), "vape_cost_per_day_cents": _round(vape_cpd), "savings_per_day_cents": _round( calc.savings_per_day_cents(cig_cpd, vape_cpd) ), "savings_theoretical_cents": _round( theoretical[-1][1] if theoretical else 0.0 ), "savings_real_cents": _round(real[-1][1] if real else 0.0), "has_purchases": _has_purchases(db, user_id), "cigarettes_avoided": avoided, "packs_avoided": _round( calc.packs_avoided(avoided, settings.cigs_per_pack) ), "time_regained_minutes": calc.time_regained_minutes(avoided), }, ) # --------------------------------------------------------------------------- # Milestones (§7.8) and dashboard # --------------------------------------------------------------------------- def milestones( db: Session, user_id: int, tz_name: str | None = None ) -> list[MilestoneRead]: settings = get_settings(db, user_id) tz = resolve_tz(tz_name) statuses = calc.milestone_statuses(settings.quit_date, tz, utcnow()) return [ MilestoneRead( code=status.code, label_fr=status.label_fr, reached_at=status.reached_at, achieved=status.achieved, progress_pct=_round(status.progress_pct) or 0.0, ) for status in statuses ] def dashboard(db: Session, user_id: int, tz_name: str | None = None) -> VapeDashboard: """One call for the home screen KPIs (§8.4 GET /vape/dashboard).""" settings = get_settings(db, user_id) tz = resolve_tz(tz_name) now = utcnow() today = local_day(now, tz) quit_date = settings.quit_date daily = _daily_ml_map(db, user_id, quit_date, today) window7 = { day: value for day, value in daily.items() if day >= today - timedelta(days=SHORT_WINDOW_DAYS - 1) } window30 = { day: value for day, value in daily.items() if day >= today - timedelta(days=DEFAULT_WINDOW_DAYS - 1) } imputed = calc.mean_ml_per_day(window30.values()) cpm = cost_per_ml_cents(db, user_id, today) ctx = coil_context(db, user_id, now) cig_cpd = calc.cig_cost_per_day_cents( settings.cigs_per_day_before, settings.cigs_per_pack, settings.cig_pack_price_cents, ) theoretical = calc.cumulative_savings_theoretical( quit_date, today, daily, imputed, cpm, ctx.cost_per_day_cents, cig_cpd ) real = calc.cumulative_savings_real( quit_date, today, _spend_by_date(db, user_id, quit_date, today), cig_cpd ) savings_theoretical = theoretical[-1][1] if theoretical else 0.0 savings_real = real[-1][1] if real else 0.0 has_purchases = _has_purchases(db, user_id) nicotine_today = _daily_nicotine_map(db, user_id, today, today, settings).get(today) elapsed = calc.days_since_quit(quit_date, today) avoided = calc.cigarettes_avoided(elapsed, settings.cigs_per_day_before) upcoming = calc.next_milestone(calc.milestone_statuses(quit_date, tz, now)) vape_cpd = calc.vape_cost_per_day_cents(imputed, cpm, ctx.cost_per_day_cents) return VapeDashboard( quit_date=quit_date, days_since_quit=elapsed, ml_today=_round(daily.get(today)), ml_per_day_7=_round(calc.mean_ml_per_day(window7.values())), ml_per_day_30=_round(imputed), nicotine_today_mg=_round(nicotine_today), cost_per_ml_cents=_round(cpm, 4), coil_cost_per_day_cents=_round(ctx.cost_per_day_cents) or 0.0, vape_cost_per_day_cents=_round(vape_cpd), cig_cost_per_day_cents=_round(cig_cpd) or 0.0, savings_theoretical_cents=_round(savings_theoretical) or 0.0, savings_real_cents=_round(savings_real) or 0.0, savings_display_cents=_round( savings_real if has_purchases else savings_theoretical ) or 0.0, has_purchases=has_purchases, cigarettes_avoided=avoided, packs_avoided=_round(calc.packs_avoided(avoided, settings.cigs_per_pack)) or 0.0, current_coil_age_days=_round(ctx.current_age_days), coil_avg_lifespan_days=_round(ctx.avg_lifespan_days) or 0.0, coil_lifespan_is_default=ctx.is_default, next_milestone=( MilestoneRead( code=upcoming.code, label_fr=upcoming.label_fr, reached_at=upcoming.reached_at, achieved=upcoming.achieved, progress_pct=_round(upcoming.progress_pct) or 0.0, ) if upcoming is not None else None ), )