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>
671 lines
21 KiB
Python
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
|
|
)
|