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>
323 lines
9.9 KiB
Python
323 lines
9.9 KiB
Python
"""Vape API (datamodel-health-vape.md §8.4) — thin routing layer.
|
|
|
|
Static sub-paths (/mixes/calculator, /coils/stats, /coils/change) are declared
|
|
before their `{id}` siblings so FastAPI never routes them to the item handler.
|
|
"""
|
|
|
|
from datetime import date
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.core.dependencies import get_current_user
|
|
from app.core.pagination import Page, PageParams
|
|
from app.modules.auth.models import User
|
|
from app.modules.vape import service
|
|
from app.modules.vape.models import LiquidEntryKind, ProductKind
|
|
from app.modules.vape.schemas import (
|
|
CoilChangeCreate,
|
|
CoilChangeCreated,
|
|
CoilChangeRead,
|
|
CoilChangeUpdate,
|
|
LiquidEntryCreate,
|
|
LiquidEntryRead,
|
|
LiquidEntryUpdate,
|
|
MilestoneRead,
|
|
MixCalculatorRequest,
|
|
MixCalculatorResult,
|
|
MixCreate,
|
|
MixRead,
|
|
MixUpdate,
|
|
ProductCreate,
|
|
ProductRead,
|
|
ProductUpdate,
|
|
PurchaseCreate,
|
|
PurchaseRead,
|
|
PurchaseUpdate,
|
|
StatsResponse,
|
|
VapeDashboard,
|
|
VapeSettingsPut,
|
|
VapeSettingsRead,
|
|
)
|
|
|
|
router = APIRouter(prefix="/vape", tags=["vape"])
|
|
|
|
DbDep = Annotated[Session, Depends(get_db)]
|
|
UserDep = Annotated[User, Depends(get_current_user)]
|
|
PageDep = Annotated[PageParams, Depends()]
|
|
FromQuery = Annotated[date | None, Query(alias="from")]
|
|
ToQuery = Annotated[date | None, Query()]
|
|
TzQuery = Annotated[str | None, Query()]
|
|
SortQuery = Annotated[str | None, Query()]
|
|
|
|
|
|
# --------------------------------------------------------------------- settings
|
|
|
|
|
|
@router.get("/settings", response_model=VapeSettingsRead)
|
|
def read_settings(db: DbDep, user: UserDep) -> VapeSettingsRead:
|
|
return VapeSettingsRead.model_validate(service.get_settings(db, user.id))
|
|
|
|
|
|
@router.put("/settings", response_model=VapeSettingsRead)
|
|
def write_settings(
|
|
payload: VapeSettingsPut, db: DbDep, user: UserDep
|
|
) -> VapeSettingsRead:
|
|
return VapeSettingsRead.model_validate(service.put_settings(db, user.id, payload))
|
|
|
|
|
|
# --------------------------------------------------------------------- products
|
|
|
|
|
|
@router.get("/products", response_model=Page[ProductRead])
|
|
def list_products(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
params: PageDep,
|
|
kind: Annotated[ProductKind | None, Query()] = None,
|
|
include_archived: Annotated[bool, Query()] = False,
|
|
sort: SortQuery = None,
|
|
) -> Page[ProductRead]:
|
|
return service.list_products(db, user.id, params, kind, include_archived, sort)
|
|
|
|
|
|
@router.post("/products", response_model=ProductRead, status_code=201)
|
|
def create_product(payload: ProductCreate, db: DbDep, user: UserDep) -> ProductRead:
|
|
return service.create_product(db, user.id, payload)
|
|
|
|
|
|
@router.patch("/products/{product_id}", response_model=ProductRead)
|
|
def update_product(
|
|
product_id: int, payload: ProductUpdate, db: DbDep, user: UserDep
|
|
) -> ProductRead:
|
|
return service.update_product(db, user.id, product_id, payload)
|
|
|
|
|
|
@router.delete("/products/{product_id}", status_code=204)
|
|
def delete_product(product_id: int, db: DbDep, user: UserDep) -> None:
|
|
service.delete_product(db, user.id, product_id)
|
|
|
|
|
|
# ------------------------------------------------------------------------ mixes
|
|
|
|
|
|
@router.get("/mixes", response_model=Page[MixRead])
|
|
def list_mixes(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
params: PageDep,
|
|
include_archived: Annotated[bool, Query()] = False,
|
|
sort: SortQuery = None,
|
|
) -> Page[MixRead]:
|
|
return service.list_mixes(db, user.id, params, include_archived, sort)
|
|
|
|
|
|
@router.post("/mixes", response_model=MixRead, status_code=201)
|
|
def create_mix(payload: MixCreate, db: DbDep, user: UserDep) -> MixRead:
|
|
return service.create_mix(db, user.id, payload)
|
|
|
|
|
|
@router.post("/mixes/calculator", response_model=MixCalculatorResult)
|
|
def mix_calculator(
|
|
payload: MixCalculatorRequest, db: DbDep, user: UserDep
|
|
) -> MixCalculatorResult:
|
|
"""Stateless recipe assistant — persists nothing (§6.3)."""
|
|
return service.mix_calculator(db, user.id, payload)
|
|
|
|
|
|
@router.post("/mixes/{mix_id}/activate", response_model=MixRead)
|
|
def activate_mix(mix_id: int, db: DbDep, user: UserDep) -> MixRead:
|
|
return service.activate_mix(db, user.id, mix_id)
|
|
|
|
|
|
@router.patch("/mixes/{mix_id}", response_model=MixRead)
|
|
def update_mix(mix_id: int, payload: MixUpdate, db: DbDep, user: UserDep) -> MixRead:
|
|
return service.update_mix(db, user.id, mix_id, payload)
|
|
|
|
|
|
@router.delete("/mixes/{mix_id}", status_code=204)
|
|
def delete_mix(mix_id: int, db: DbDep, user: UserDep) -> None:
|
|
service.delete_mix(db, user.id, mix_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------- liquids
|
|
|
|
|
|
@router.get("/liquids", response_model=Page[LiquidEntryRead])
|
|
def list_liquids(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
params: PageDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
kind: Annotated[LiquidEntryKind | None, Query()] = None,
|
|
sort: SortQuery = None,
|
|
) -> Page[LiquidEntryRead]:
|
|
return service.list_liquids(db, user.id, params, from_, to, kind, sort)
|
|
|
|
|
|
@router.post("/liquids", response_model=LiquidEntryRead, status_code=201)
|
|
def create_liquid(
|
|
payload: LiquidEntryCreate, db: DbDep, user: UserDep
|
|
) -> LiquidEntryRead:
|
|
return service.create_liquid(db, user.id, payload)
|
|
|
|
|
|
@router.patch("/liquids/{entry_id}", response_model=LiquidEntryRead)
|
|
def update_liquid(
|
|
entry_id: int, payload: LiquidEntryUpdate, db: DbDep, user: UserDep
|
|
) -> LiquidEntryRead:
|
|
return service.update_liquid(db, user.id, entry_id, payload)
|
|
|
|
|
|
@router.delete("/liquids/{entry_id}", status_code=204)
|
|
def delete_liquid(entry_id: int, db: DbDep, user: UserDep) -> None:
|
|
service.delete_liquid(db, user.id, entry_id)
|
|
|
|
|
|
# ------------------------------------------------------------------------ coils
|
|
|
|
|
|
@router.get("/coils", response_model=Page[CoilChangeRead])
|
|
def list_coils(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
params: PageDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
sort: SortQuery = None,
|
|
tz: TzQuery = None,
|
|
) -> Page[CoilChangeRead]:
|
|
return service.list_coils(db, user.id, params, from_, to, sort, tz)
|
|
|
|
|
|
@router.get("/coils/stats", response_model=StatsResponse)
|
|
def coil_stats(db: DbDep, user: UserDep, tz: TzQuery = None) -> StatsResponse:
|
|
return service.coil_stats(db, user.id, tz)
|
|
|
|
|
|
@router.post("/coils/change", response_model=CoilChangeCreated, status_code=201)
|
|
def quick_coil_change(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
payload: CoilChangeCreate | None = None,
|
|
tz: TzQuery = None,
|
|
) -> CoilChangeCreated:
|
|
"""One-click « Résistance changée » : registers a change at `now` (§12.6)."""
|
|
return service.create_coil(db, user.id, payload or CoilChangeCreate(), tz)
|
|
|
|
|
|
@router.post("/coils", response_model=CoilChangeCreated, status_code=201)
|
|
def create_coil(
|
|
payload: CoilChangeCreate, db: DbDep, user: UserDep, tz: TzQuery = None
|
|
) -> CoilChangeCreated:
|
|
return service.create_coil(db, user.id, payload, tz)
|
|
|
|
|
|
@router.patch("/coils/{coil_id}", response_model=CoilChangeRead)
|
|
def update_coil(
|
|
coil_id: int,
|
|
payload: CoilChangeUpdate,
|
|
db: DbDep,
|
|
user: UserDep,
|
|
tz: TzQuery = None,
|
|
) -> CoilChangeRead:
|
|
return service.update_coil(db, user.id, coil_id, payload, tz)
|
|
|
|
|
|
@router.delete("/coils/{coil_id}", status_code=204)
|
|
def delete_coil(coil_id: int, db: DbDep, user: UserDep) -> None:
|
|
service.delete_coil(db, user.id, coil_id)
|
|
|
|
|
|
# -------------------------------------------------------------------- purchases
|
|
|
|
|
|
@router.get("/purchases", response_model=Page[PurchaseRead])
|
|
def list_purchases(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
params: PageDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
product_id: Annotated[int | None, Query()] = None,
|
|
sort: SortQuery = None,
|
|
) -> Page[PurchaseRead]:
|
|
return service.list_purchases(db, user.id, params, from_, to, product_id, sort)
|
|
|
|
|
|
@router.post("/purchases", response_model=PurchaseRead, status_code=201)
|
|
def create_purchase(payload: PurchaseCreate, db: DbDep, user: UserDep) -> PurchaseRead:
|
|
return service.create_purchase(db, user.id, payload)
|
|
|
|
|
|
@router.patch("/purchases/{purchase_id}", response_model=PurchaseRead)
|
|
def update_purchase(
|
|
purchase_id: int, payload: PurchaseUpdate, db: DbDep, user: UserDep
|
|
) -> PurchaseRead:
|
|
return service.update_purchase(db, user.id, purchase_id, payload)
|
|
|
|
|
|
@router.delete("/purchases/{purchase_id}", status_code=204)
|
|
def delete_purchase(purchase_id: int, db: DbDep, user: UserDep) -> None:
|
|
service.delete_purchase(db, user.id, purchase_id)
|
|
|
|
|
|
# ------------------------------------------------------------- stats & overview
|
|
|
|
|
|
@router.get("/stats/consumption", response_model=StatsResponse)
|
|
def consumption_stats(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
tz: TzQuery = None,
|
|
) -> StatsResponse:
|
|
return service.consumption_stats(db, user.id, from_, to, tz)
|
|
|
|
|
|
@router.get("/stats/nicotine", response_model=StatsResponse)
|
|
def nicotine_stats(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
tz: TzQuery = None,
|
|
) -> StatsResponse:
|
|
return service.nicotine_stats(db, user.id, from_, to, tz)
|
|
|
|
|
|
@router.get("/stats/costs", response_model=StatsResponse)
|
|
def cost_stats(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
tz: TzQuery = None,
|
|
) -> StatsResponse:
|
|
return service.cost_stats(db, user.id, from_, to, tz)
|
|
|
|
|
|
@router.get("/stats/savings", response_model=StatsResponse)
|
|
def savings_stats(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
from_: FromQuery = None,
|
|
to: ToQuery = None,
|
|
tz: TzQuery = None,
|
|
) -> StatsResponse:
|
|
return service.savings_stats(db, user.id, from_, to, tz)
|
|
|
|
|
|
@router.get("/milestones", response_model=list[MilestoneRead])
|
|
def milestones(db: DbDep, user: UserDep, tz: TzQuery = None) -> list[MilestoneRead]:
|
|
return service.milestones(db, user.id, tz)
|
|
|
|
|
|
@router.get("/dashboard", response_model=VapeDashboard)
|
|
def dashboard(db: DbDep, user: UserDep, tz: TzQuery = None) -> VapeDashboard:
|
|
return service.dashboard(db, user.id, tz)
|