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>
1591 lines
53 KiB
Python
1591 lines
53 KiB
Python
"""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
|
|
),
|
|
)
|