Files
lifetrack/apps/api/app/modules/vape/schemas.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

330 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic v2 schemas for the vape module (API contract §8.4).
Money crosses the API in euro cents: integers for stored amounts,
floats for derived rates (cost/ml, cost/day, cumulative savings).
"""
from datetime import date, datetime
from decimal import Decimal
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
from app.core.timeutils import UtcDatetime
from app.modules.vape.models import LiquidEntryKind, ProductKind, SizeUnit
# `Numeric` columns (ml, mg/ml, %, Ω, quantities) cross the API as JSON *numbers*,
# never strings: Pydantic serialises `Decimal` as a string by default, which would
# break the fr-FR formatters and the ECharts series of the frontend. Validation
# still runs on Decimal, so precision is kept end to end — same trade-off as the
# `Money` alias of the finance module and the float columns of `health`.
Quantity = Annotated[
Decimal, PlainSerializer(float, return_type=float, when_used="json")
]
# ---------------------------------------------------------------------------
# Settings (singleton per user)
# ---------------------------------------------------------------------------
class VapeSettingsPut(BaseModel):
quit_date: date
cigs_per_day_before: Quantity = Field(ge=0, le=200)
cig_pack_price_cents: int = Field(ge=0)
cigs_per_pack: int = Field(default=20, gt=0)
default_nicotine_mg_ml: Quantity = Field(ge=0, le=100)
currency: str = Field(default="EUR", min_length=3, max_length=3)
class VapeSettingsRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
quit_date: date
cigs_per_day_before: Quantity
cig_pack_price_cents: int
cigs_per_pack: int
default_nicotine_mg_ml: Quantity
currency: str
# ---------------------------------------------------------------------------
# Products
# ---------------------------------------------------------------------------
class ProductCreate(BaseModel):
kind: ProductKind
name: str = Field(min_length=1, max_length=150)
brand: str | None = Field(default=None, max_length=100)
price_cents: int = Field(ge=0)
size_value: Quantity = Field(gt=0)
size_unit: SizeUnit
nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100)
vg_pct: Quantity | None = Field(default=None, ge=0, le=100)
ohm: Quantity | None = Field(default=None, gt=0)
is_archived: bool = False
note: str | None = Field(default=None, max_length=255)
class ProductUpdate(BaseModel):
kind: ProductKind | None = None
name: str | None = Field(default=None, min_length=1, max_length=150)
brand: str | None = Field(default=None, max_length=100)
price_cents: int | None = Field(default=None, ge=0)
size_value: Quantity | None = Field(default=None, gt=0)
size_unit: SizeUnit | None = None
nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100)
vg_pct: Quantity | None = Field(default=None, ge=0, le=100)
ohm: Quantity | None = Field(default=None, gt=0)
is_archived: bool | None = None
note: str | None = Field(default=None, max_length=255)
class ProductRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
kind: ProductKind
name: str
brand: str | None
price_cents: int
size_value: Quantity
size_unit: SizeUnit
nicotine_mg_ml: Quantity | None
vg_pct: Quantity | None
ohm: Quantity | None
is_archived: bool
note: str | None
unit_price_cents: float # derived: price_cents / size_value
# ---------------------------------------------------------------------------
# Mixes
# ---------------------------------------------------------------------------
class MixComponentIn(BaseModel):
product_id: int
quantity: Quantity = Field(gt=0)
class MixCreate(BaseModel):
name: str = Field(min_length=1, max_length=150)
total_ml: Quantity = Field(gt=0)
target_nicotine_mg_ml: Quantity = Field(ge=0, le=100)
note: str | None = Field(default=None, max_length=255)
components: list[MixComponentIn] = Field(default_factory=list)
class MixUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
total_ml: Quantity | None = Field(default=None, gt=0)
target_nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100)
is_archived: bool | None = None
note: str | None = Field(default=None, max_length=255)
# Full replacement when provided (§8.4)
components: list[MixComponentIn] | None = None
class MixComponentRead(BaseModel):
id: int
product_id: int
product_name: str
product_kind: ProductKind
quantity: Quantity
cost_cents: float
class MixRead(BaseModel):
id: int
name: str
total_ml: Quantity
target_nicotine_mg_ml: Quantity
is_active: bool
is_archived: bool
note: str | None
components: list[MixComponentRead]
cost_total_cents: float
cost_per_ml_cents: float | None
nicotine_check_mg_ml: float
vg_pct_mix: float | None
warning: str | None # "nicotine_mismatch" when > 10 % off target
class MixCalculatorRequest(BaseModel):
total_ml: Quantity = Field(gt=0)
target_nicotine_mg_ml: Quantity = Field(ge=0, le=100)
booster_product_id: int
base_product_id: int
aroma_pct: Quantity = Field(default=Decimal(0), ge=0, le=100)
aroma_product_id: int | None = None
class MixCalculatorResult(BaseModel):
total_ml: Quantity
target_nicotine_mg_ml: Quantity
booster_ml: float
aroma_ml: float
base_ml: float
nicotine_check_mg_ml: float
cost_total_cents: float
cost_per_ml_cents: float
# ---------------------------------------------------------------------------
# Liquid entries
# ---------------------------------------------------------------------------
class LiquidEntryCreate(BaseModel):
entry_date: date
ml: Quantity = Field(gt=0, le=100)
kind: LiquidEntryKind = LiquidEntryKind.REFILL
nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100)
mix_id: int | None = None
note: str | None = Field(default=None, max_length=255)
class LiquidEntryUpdate(BaseModel):
entry_date: date | None = None
ml: Quantity | None = Field(default=None, gt=0, le=100)
kind: LiquidEntryKind | None = None
nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100)
mix_id: int | None = None
note: str | None = Field(default=None, max_length=255)
class LiquidEntryRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
entry_date: date
kind: LiquidEntryKind
ml: Quantity
nicotine_mg_ml: Quantity | None
nicotine_effective_mg_ml: float | None # override -> mix -> settings default
nicotine_mg: float | None # ml × effective rate
mix_id: int | None
source: str
note: str | None
# ---------------------------------------------------------------------------
# Coil changes
# ---------------------------------------------------------------------------
class CoilChangeCreate(BaseModel):
changed_at: UtcDatetime | None = None # default: now (one-click)
product_id: int | None = None
reason: str | None = Field(default=None, max_length=50)
note: str | None = Field(default=None, max_length=255)
class CoilChangeUpdate(BaseModel):
changed_at: UtcDatetime | None = None
product_id: int | None = None
reason: str | None = Field(default=None, max_length=50)
note: str | None = Field(default=None, max_length=255)
class CoilChangeRead(BaseModel):
id: int
changed_at: datetime
product_id: int | None
product_name: str | None
reason: str | None
note: str | None
is_current: bool
lifespan_days: float | None # provisional (now changed_at) for the current one
ml_through: float | None # Σ daily ml over the coil's interval
class CoilChangeCreated(CoilChangeRead):
previous_lifespan_days: float | None # for the one-click toast
# ---------------------------------------------------------------------------
# Purchases
# ---------------------------------------------------------------------------
class PurchaseCreate(BaseModel):
purchased_on: date
product_id: int
qty: Quantity = Field(default=Decimal(1), gt=0)
unit_price_cents: int | None = Field(default=None, ge=0)
note: str | None = Field(default=None, max_length=255)
class PurchaseUpdate(BaseModel):
purchased_on: date | None = None
product_id: int | None = None
qty: Quantity | None = Field(default=None, gt=0)
unit_price_cents: int | None = Field(default=None, ge=0)
note: str | None = Field(default=None, max_length=255)
class PurchaseRead(BaseModel):
id: int
purchased_on: date
product_id: int
product_name: str
qty: Quantity
unit_price_cents: int | None
total_cents: float # qty × coalesce(unit_price_cents, catalog price)
note: str | None
# ---------------------------------------------------------------------------
# Stats (ECharts-ready, §8.1) / milestones / dashboard
# ---------------------------------------------------------------------------
class SeriesModel(BaseModel):
name: str
type: str # "line" | "scatter" | "bar"
points: list[tuple[str, float | None]]
class StatsResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: date = Field(alias="from")
to: date
unit: str # "ml" | "mg" | "cents" | "days"
series: list[SeriesModel]
meta: dict[str, Any]
class MilestoneRead(BaseModel):
code: str
label_fr: str
reached_at: datetime
achieved: bool
progress_pct: float
class VapeDashboard(BaseModel):
quit_date: date
days_since_quit: int
ml_today: float | None
ml_per_day_7: float | None
ml_per_day_30: float | None
nicotine_today_mg: float | None
cost_per_ml_cents: float | None
coil_cost_per_day_cents: float
vape_cost_per_day_cents: float | None
cig_cost_per_day_cents: float
savings_theoretical_cents: float
savings_real_cents: float
savings_display_cents: float # real when a purchase exists, else theoretical
has_purchases: bool
cigarettes_avoided: int
packs_avoided: float
current_coil_age_days: float | None
coil_avg_lifespan_days: float
coil_lifespan_is_default: bool
next_milestone: MilestoneRead | None