Files
lifetrack/apps/api/app/modules/health/router.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

671 lines
21 KiB
Python

"""HTTP surface of the health module — prefix /api/health (CONVENTIONS C2.1).
Routers stay thin: dependencies + service/stats call + response schema.
Static sub-paths are declared before their `/{id}` siblings on purpose.
"""
import datetime as dt
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, paginate
from app.modules.auth.models import User
from app.modules.health import calculations as calc
from app.modules.health import foods as foods_service
from app.modules.health import service, stats
from app.modules.health.models import (
GoalStatus,
MealType,
ScheduleKind,
SportType,
)
from app.modules.health.schemas import (
ActiveGoalRead,
AdherenceResponse,
BodyMeasurementCreate,
BodyMeasurementRead,
BodyMeasurementUpdate,
DailyActivityRead,
DailyActivityUpsert,
DashboardResponse,
FoodEntryCreate,
FoodEntryRead,
FoodEntryUpdate,
FoodFavoriteCreate,
FoodFavoriteRead,
FoodFavoriteUpdate,
FoodSearchItem,
FoodSearchResponse,
GoalCreate,
GoalRead,
GoalUpdate,
HealthProfileRead,
HealthProfileUpdate,
MergedActivityRead,
NutritionDayDetail,
NutritionDayRead,
RecentFoodRead,
ScheduleRead,
ScheduleUpdate,
StatsResponse,
TodayResponse,
WaterEntryCreate,
WaterEntryRead,
WeightEntryCreate,
WeightEntryRead,
WeightEntryUpdate,
WorkoutCreate,
WorkoutRead,
WorkoutUpdate,
)
router = APIRouter(prefix="/health", tags=["health"])
DbDep = Annotated[Session, Depends(get_db)]
UserDep = Annotated[User, Depends(get_current_user)]
PageDep = Annotated[PageParams, Depends()]
FromQ = Annotated[dt.date | None, Query(alias="from")]
ToQ = Annotated[dt.date | None, Query()]
TzQ = Annotated[str | None, Query()]
SortQ = Annotated[str | None, Query()]
def _page(items: list, total: int, params: PageParams, schema) -> Page:
return Page(
items=[schema.model_validate(item) for item in items],
total=total,
page=params.page,
page_size=params.page_size,
)
# --- Profile ------------------------------------------------------------------
def _profile_read(db: Session, user_id: int) -> HealthProfileRead:
profile = service.require_profile(db, user_id)
read = HealthProfileRead.model_validate(profile)
tz = service.profile_tz(db, user_id, None)
today = service.today_local(tz)
read.age = calc.age_on(today, profile.birthdate)
trend = calc.weight_trend(service.daily_weights(db, user_id, tz))
weight = trend[-1][1] if trend else None
read.current_weight_kg = None if weight is None else round(weight, 2)
if weight is not None:
read.bmr_kcal = round(
calc.bmr_mifflin(
weight, float(profile.height_cm), read.age, profile.sex.value
),
1,
)
read.tdee_estimated_kcal = round(
read.bmr_kcal * calc.ACTIVITY_FACTORS[profile.activity_level.value], 1
)
bmi = calc.bmi(weight, float(profile.height_cm))
read.bmi = None if bmi is None else round(bmi, 1)
return read
@router.get("/profile", response_model=HealthProfileRead)
def get_profile(db: DbDep, user: UserDep) -> HealthProfileRead:
return _profile_read(db, user.id)
@router.put("/profile", response_model=HealthProfileRead)
def put_profile(
payload: HealthProfileUpdate, db: DbDep, user: UserDep
) -> HealthProfileRead:
service.upsert_profile(db, user.id, payload)
return _profile_read(db, user.id)
# --- Weights ------------------------------------------------------------------
@router.get("/weights/stats", response_model=StatsResponse)
def weights_stats(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone, default_days=90)
return stats.weight_stats(db, user.id, start, end, zone)
@router.get("/weights", response_model=Page[WeightEntryRead])
def list_weights(
db: DbDep,
user: UserDep,
params: PageDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
sort: SortQ = None,
) -> Page[WeightEntryRead]:
zone = service.profile_tz(db, user.id, tz)
stmt = service.weights_query(user.id, from_, to, zone, sort)
items, total = paginate(db, stmt, params)
return _page(items, total, params, WeightEntryRead)
@router.post("/weights", response_model=WeightEntryRead, status_code=201)
def create_weight(
payload: WeightEntryCreate, db: DbDep, user: UserDep
) -> WeightEntryRead:
return WeightEntryRead.model_validate(service.create_weight(db, user.id, payload))
@router.patch("/weights/{entry_id}", response_model=WeightEntryRead)
@router.put("/weights/{entry_id}", response_model=WeightEntryRead)
def update_weight(
entry_id: int, payload: WeightEntryUpdate, db: DbDep, user: UserDep
) -> WeightEntryRead:
return WeightEntryRead.model_validate(
service.update_weight(db, user.id, entry_id, payload)
)
@router.delete("/weights/{entry_id}", status_code=204)
def delete_weight(entry_id: int, db: DbDep, user: UserDep) -> None:
service.delete_weight(db, user.id, entry_id)
# --- Body measurements --------------------------------------------------------
@router.get("/measurements/stats", response_model=StatsResponse)
def measurements_stats(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone, default_days=180)
return stats.measurement_stats(db, user.id, start, end, zone)
@router.get("/measurements", response_model=Page[BodyMeasurementRead])
def list_measurements(
db: DbDep,
user: UserDep,
params: PageDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
sort: SortQ = None,
) -> Page[BodyMeasurementRead]:
zone = service.profile_tz(db, user.id, tz)
stmt = service.measurements_query(user.id, from_, to, zone, sort)
items, total = paginate(db, stmt, params)
return _page(items, total, params, BodyMeasurementRead)
@router.post("/measurements", response_model=BodyMeasurementRead, status_code=201)
def create_measurement(
payload: BodyMeasurementCreate, db: DbDep, user: UserDep
) -> BodyMeasurementRead:
return BodyMeasurementRead.model_validate(
service.create_measurement(db, user.id, payload)
)
@router.patch("/measurements/{row_id}", response_model=BodyMeasurementRead)
@router.put("/measurements/{row_id}", response_model=BodyMeasurementRead)
def update_measurement(
row_id: int, payload: BodyMeasurementUpdate, db: DbDep, user: UserDep
) -> BodyMeasurementRead:
return BodyMeasurementRead.model_validate(
service.update_measurement(db, user.id, row_id, payload)
)
@router.delete("/measurements/{row_id}", status_code=204)
def delete_measurement(row_id: int, db: DbDep, user: UserDep) -> None:
service.delete_measurement(db, user.id, row_id)
# --- Daily activity -----------------------------------------------------------
@router.get("/activity/stats", response_model=StatsResponse)
def activity_stats(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone)
return stats.activity_stats(db, user.id, start, end, zone)
@router.get("/activity", response_model=None)
def list_activity(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
raw: Annotated[bool, Query()] = False,
) -> list[MergedActivityRead] | list[DailyActivityRead]:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone)
if raw:
return [
DailyActivityRead.model_validate(row)
for row in service.activity_rows(db, user.id, start, end)
]
merged = service.merged_activity(db, user.id, start, end)
return [
MergedActivityRead(
date=day,
steps=value.steps,
active_kcal=value.active_kcal,
total_kcal=value.total_kcal,
distance_m=value.distance_m,
active_minutes=value.active_minutes,
floors=value.floors,
field_sources=value.field_sources,
)
for day, value in sorted(merged.items())
]
@router.post("/activity", response_model=DailyActivityRead, status_code=201)
def upsert_activity(
payload: DailyActivityUpsert, db: DbDep, user: UserDep
) -> DailyActivityRead:
return DailyActivityRead.model_validate(
service.upsert_manual_activity(db, user.id, payload)
)
@router.delete("/activity/{row_id}", status_code=204)
def delete_activity(row_id: int, db: DbDep, user: UserDep) -> None:
service.delete_activity(db, user.id, row_id)
# --- Workouts -----------------------------------------------------------------
@router.get("/workouts/stats", response_model=StatsResponse)
def workouts_stats(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone, default_days=90)
return stats.workout_stats(db, user.id, start, end, zone)
@router.get("/workouts", response_model=Page[WorkoutRead])
def list_workouts(
db: DbDep,
user: UserDep,
params: PageDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
sport_type: Annotated[SportType | None, Query()] = None,
include_hidden: Annotated[bool, Query()] = False,
sort: SortQ = None,
) -> Page[WorkoutRead]:
zone = service.profile_tz(db, user.id, tz)
stmt = service.workouts_query(
user.id,
from_,
to,
zone,
sport_type.value if sport_type else None,
include_hidden,
sort,
)
items, total = paginate(db, stmt, params)
return _page(items, total, params, WorkoutRead)
@router.post("/workouts", response_model=WorkoutRead, status_code=201)
def create_workout(payload: WorkoutCreate, db: DbDep, user: UserDep) -> WorkoutRead:
return WorkoutRead.model_validate(service.create_workout(db, user.id, payload))
@router.patch("/workouts/{workout_id}", response_model=WorkoutRead)
@router.put("/workouts/{workout_id}", response_model=WorkoutRead)
def update_workout(
workout_id: int, payload: WorkoutUpdate, db: DbDep, user: UserDep
) -> WorkoutRead:
return WorkoutRead.model_validate(
service.update_workout(db, user.id, workout_id, payload)
)
@router.delete("/workouts/{workout_id}", status_code=204)
def delete_workout(workout_id: int, db: DbDep, user: UserDep) -> None:
service.delete_workout(db, user.id, workout_id)
# --- Goals --------------------------------------------------------------------
@router.get("/goals/active", response_model=ActiveGoalRead)
def get_active_goal(db: DbDep, user: UserDep, tz: TzQ = None) -> ActiveGoalRead:
zone = service.profile_tz(db, user.id, tz)
return stats.active_goal_view(db, user.id, zone)
@router.get("/goals", response_model=Page[GoalRead])
def list_goals(
db: DbDep,
user: UserDep,
params: PageDep,
status: Annotated[GoalStatus | None, Query()] = None,
sort: SortQ = None,
) -> Page[GoalRead]:
stmt = service.goals_query(user.id, status.value if status else None, sort)
items, total = paginate(db, stmt, params)
return _page(items, total, params, GoalRead)
@router.post("/goals", response_model=GoalRead, status_code=201)
def create_goal(
payload: GoalCreate,
db: DbDep,
user: UserDep,
replace_active: Annotated[bool, Query()] = False,
) -> GoalRead:
return GoalRead.model_validate(
service.create_goal(db, user.id, payload, replace_active)
)
@router.post("/goals/{goal_id}/activate", response_model=GoalRead)
def activate_goal(goal_id: int, db: DbDep, user: UserDep) -> GoalRead:
return GoalRead.model_validate(service.activate_goal(db, user.id, goal_id))
@router.patch("/goals/{goal_id}", response_model=GoalRead)
@router.put("/goals/{goal_id}", response_model=GoalRead)
def update_goal(
goal_id: int, payload: GoalUpdate, db: DbDep, user: UserDep
) -> GoalRead:
return GoalRead.model_validate(service.update_goal(db, user.id, goal_id, payload))
@router.delete("/goals/{goal_id}", status_code=204)
def delete_goal(goal_id: int, db: DbDep, user: UserDep) -> None:
service.delete_goal(db, user.id, goal_id)
# --- Energy balance & dashboard -----------------------------------------------
@router.get("/energy-balance", response_model=StatsResponse)
def energy_balance(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone)
return stats.energy_balance_stats(db, user.id, start, end, zone)
@router.get("/dashboard", response_model=DashboardResponse)
def dashboard(db: DbDep, user: UserDep, tz: TzQ = None) -> DashboardResponse:
zone = service.profile_tz(db, user.id, tz)
return stats.dashboard(db, user.id, zone)
# --- Nutrition: journal --------------------------------------------------------
@router.get("/nutrition/stats", response_model=StatsResponse)
def nutrition_stats(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone)
return stats.nutrition_stats(db, user.id, start, end, zone)
@router.get("/nutrition/days", response_model=list[NutritionDayRead])
def nutrition_days(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> list[NutritionDayRead]:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone)
return stats.nutrition_days(db, user.id, start, end, zone)
@router.get("/nutrition/days/{day}", response_model=NutritionDayDetail)
def nutrition_day(
day: dt.date, db: DbDep, user: UserDep, tz: TzQ = None
) -> NutritionDayDetail:
zone = service.profile_tz(db, user.id, tz)
return stats.nutrition_day_detail(db, user.id, day, zone)
@router.get("/nutrition/recent", response_model=list[RecentFoodRead])
def recent_foods(
db: DbDep,
user: UserDep,
limit: Annotated[int, Query(ge=1, le=50)] = 20,
) -> list[RecentFoodRead]:
return [
RecentFoodRead(
name=entry.name,
brand=entry.brand,
unit=entry.unit,
quantity=float(entry.quantity),
kcal=float(entry.kcal),
protein_g=float(entry.protein_g) if entry.protein_g is not None else None,
carbs_g=float(entry.carbs_g) if entry.carbs_g is not None else None,
fat_g=float(entry.fat_g) if entry.fat_g is not None else None,
meal=entry.meal,
last_eaten_at=entry.eaten_at,
)
for entry in service.recent_foods(db, user.id, limit)
]
@router.get("/nutrition/favorites", response_model=Page[FoodFavoriteRead])
def list_favorites(
db: DbDep, user: UserDep, params: PageDep, sort: SortQ = None
) -> Page[FoodFavoriteRead]:
stmt = service.favorites_query(user.id, sort)
items, total = paginate(db, stmt, params)
return _page(items, total, params, FoodFavoriteRead)
@router.post("/nutrition/favorites", response_model=FoodFavoriteRead, status_code=201)
def create_favorite(
payload: FoodFavoriteCreate, db: DbDep, user: UserDep
) -> FoodFavoriteRead:
return FoodFavoriteRead.model_validate(
service.create_favorite(db, user.id, payload)
)
@router.patch("/nutrition/favorites/{favorite_id}", response_model=FoodFavoriteRead)
@router.put("/nutrition/favorites/{favorite_id}", response_model=FoodFavoriteRead)
def update_favorite(
favorite_id: int, payload: FoodFavoriteUpdate, db: DbDep, user: UserDep
) -> FoodFavoriteRead:
return FoodFavoriteRead.model_validate(
service.update_favorite(db, user.id, favorite_id, payload)
)
@router.delete("/nutrition/favorites/{favorite_id}", status_code=204)
def delete_favorite(favorite_id: int, db: DbDep, user: UserDep) -> None:
service.delete_favorite(db, user.id, favorite_id)
@router.get("/nutrition/water/stats", response_model=StatsResponse)
def water_stats(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
) -> StatsResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone)
return stats.water_stats(db, user.id, start, end, zone)
@router.get("/nutrition/water", response_model=Page[WaterEntryRead])
def list_water(
db: DbDep,
user: UserDep,
params: PageDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
sort: SortQ = None,
) -> Page[WaterEntryRead]:
zone = service.profile_tz(db, user.id, tz)
stmt = service.water_query(user.id, from_, to, zone, sort)
items, total = paginate(db, stmt, params)
return _page(items, total, params, WaterEntryRead)
@router.post("/nutrition/water", response_model=WaterEntryRead, status_code=201)
def create_water(payload: WaterEntryCreate, db: DbDep, user: UserDep) -> WaterEntryRead:
return WaterEntryRead.model_validate(service.create_water(db, user.id, payload))
@router.delete("/nutrition/water/{entry_id}", status_code=204)
def delete_water(entry_id: int, db: DbDep, user: UserDep) -> None:
service.delete_water(db, user.id, entry_id)
@router.get("/nutrition/entries", response_model=Page[FoodEntryRead])
def list_food_entries(
db: DbDep,
user: UserDep,
params: PageDep,
day: Annotated[dt.date | None, Query()] = None,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
meal: Annotated[MealType | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
sort: SortQ = None,
) -> Page[FoodEntryRead]:
zone = service.profile_tz(db, user.id, tz)
stmt = service.food_entries_query(
user.id, zone, day, from_, to, meal.value if meal else None, q, sort
)
items, total = paginate(db, stmt, params)
return _page(items, total, params, FoodEntryRead)
@router.post("/nutrition/entries", response_model=FoodEntryRead, status_code=201)
def create_food_entry(
payload: FoodEntryCreate, db: DbDep, user: UserDep
) -> FoodEntryRead:
return FoodEntryRead.model_validate(service.create_food_entry(db, user.id, payload))
@router.patch("/nutrition/entries/{entry_id}", response_model=FoodEntryRead)
@router.put("/nutrition/entries/{entry_id}", response_model=FoodEntryRead)
def update_food_entry(
entry_id: int, payload: FoodEntryUpdate, db: DbDep, user: UserDep
) -> FoodEntryRead:
return FoodEntryRead.model_validate(
service.update_food_entry(db, user.id, entry_id, payload)
)
@router.delete("/nutrition/entries/{entry_id}", status_code=204)
def delete_food_entry(entry_id: int, db: DbDep, user: UserDep) -> None:
service.delete_food_entry(db, user.id, entry_id)
# --- Food referential (Open Food Facts proxy + local cache) -------------------
@router.get("/foods/search", response_model=FoodSearchResponse)
def search_foods(
db: DbDep,
user: UserDep,
q: Annotated[str, Query(min_length=1, max_length=100)],
limit: Annotated[int, Query(ge=1, le=50)] = 20,
) -> FoodSearchResponse:
return foods_service.search_foods(db, q, limit)
@router.get("/foods/barcode/{barcode}", response_model=FoodSearchItem)
def food_by_barcode(barcode: str, db: DbDep, user: UserDep) -> FoodSearchItem:
return foods_service.food_by_barcode(db, barcode)
# --- Planning (addendum-planning.md) ------------------------------------------
@router.get("/schedules", response_model=list[ScheduleRead])
def list_schedules(db: DbDep, user: UserDep) -> list[ScheduleRead]:
return [
ScheduleRead(
kind=row.kind, weekdays=list(row.weekdays or []), enabled=row.enabled
)
for row in service.list_schedules(db, user.id)
]
@router.put("/schedules/{kind}", response_model=ScheduleRead)
def put_schedule(
kind: ScheduleKind, payload: ScheduleUpdate, db: DbDep, user: UserDep
) -> ScheduleRead:
row = service.upsert_schedule(db, user.id, kind, payload)
return ScheduleRead(
kind=row.kind, weekdays=list(row.weekdays or []), enabled=row.enabled
)
@router.get("/today", response_model=TodayResponse)
def today(db: DbDep, user: UserDep, tz: TzQ = None) -> TodayResponse:
zone = service.profile_tz(db, user.id, tz)
return stats.today_view(db, user.id, zone)
@router.get("/stats/adherence", response_model=AdherenceResponse)
def adherence(
db: DbDep,
user: UserDep,
from_: FromQ = None,
to: ToQ = None,
tz: TzQ = None,
kind: Annotated[ScheduleKind | None, Query()] = None,
) -> AdherenceResponse:
zone = service.profile_tz(db, user.id, tz)
start, end = service.resolve_range(from_, to, zone, default_days=90)
return stats.adherence_stats(
db, user.id, start, end, zone, kind.value if kind else None
)