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>
330 lines
10 KiB
Python
330 lines
10 KiB
Python
"""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
|