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

1094 lines
35 KiB
Python

"""Business logic for the health module (CONVENTIONS C2.6).
Every function takes (db, user_id, ...) and filters on user_id. Errors are
AppError subclasses with French messages; routers stay thin.
"""
import datetime as dt
from decimal import Decimal
from zoneinfo import ZoneInfo
from sqlalchemy import Select, and_, or_, select
from sqlalchemy.orm import Session
from app.core.errors import ConflictError, DomainValidationError, NotFoundError
from app.core.timeutils import resolve_tz, utcnow
from app.modules.health import calculations as calc
from app.modules.health.models import (
BodyMeasurement,
DailyActivity,
FoodEntry,
FoodFavorite,
Goal,
GoalStatus,
HealthProfile,
MealType,
ScheduleKind,
TrackingSchedule,
WaterEntry,
WeightEntry,
Workout,
)
from app.modules.health.schemas import (
BodyMeasurementCreate,
BodyMeasurementUpdate,
DailyActivityUpsert,
FoodEntryCreate,
FoodEntryUpdate,
FoodFavoriteCreate,
FoodFavoriteUpdate,
GoalCreate,
GoalUpdate,
HealthProfileUpdate,
ScheduleUpdate,
WaterEntryCreate,
WeightEntryCreate,
WeightEntryUpdate,
WorkoutCreate,
WorkoutUpdate,
)
MANUAL_SOURCE = "manual"
DEFAULT_WEEKDAYS: list[int] = []
# Day-series endpoints return one point per day: cap the window (~3 years).
MAX_RANGE_DAYS = 1100
# --- Generic helpers ----------------------------------------------------------
def _dec(value: float | Decimal | None) -> Decimal | None:
"""Portable float -> Decimal conversion (avoids binary float artefacts)."""
if value is None:
return None
return Decimal(str(value))
def to_utc(value: dt.datetime | None) -> dt.datetime | None:
"""Every stored instant is UTC-aware (C2.3); naive input is read as UTC."""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=dt.UTC)
return value.astimezone(dt.UTC)
def local_day_of(value: dt.datetime, tz: ZoneInfo) -> dt.date:
"""Local civil day of an instant (§1.3 aggregation convention)."""
return to_utc(value).astimezone(tz).date()
_DATETIME_FIELDS = frozenset(
{"measured_at", "eaten_at", "drunk_at", "started_at", "ended_at"}
)
def _apply_sort(stmt: Select, sort: str | None, allowed: dict, default: str) -> Select:
"""`?sort=field` / `-field` with an explicit whitelist (C2.8)."""
raw = sort or default
descending = raw.startswith("-")
name = raw.lstrip("-")
column = allowed.get(name)
if column is None:
raise DomainValidationError("Champ de tri non autorisé.", details={"sort": raw})
return stmt.order_by(column.desc() if descending else column.asc())
def utc_window(
start: dt.date, end: dt.date, tz: ZoneInfo
) -> tuple[dt.datetime, dt.datetime]:
"""UTC bounds [start 00:00 local, end+1 00:00 local) for a local-day range."""
begin = dt.datetime.combine(start, dt.time.min, tzinfo=tz)
stop = dt.datetime.combine(end + dt.timedelta(days=1), dt.time.min, tzinfo=tz)
return begin.astimezone(dt.UTC), stop.astimezone(dt.UTC)
def resolve_range(
from_: dt.date | None,
to: dt.date | None,
tz: ZoneInfo,
default_days: int = 30,
max_days: int | None = MAX_RANGE_DAYS,
) -> tuple[dt.date, dt.date]:
"""Inclusive local-day range; defaults to the trailing `default_days`.
`max_days` caps day-series endpoints (one point per day, no pagination);
paginated list queries pass `max_days=None`.
"""
end = to or today_local(tz)
start = from_ or end - dt.timedelta(days=default_days - 1)
if start > end:
raise DomainValidationError(
"La date de début doit précéder la date de fin.",
details={"from": str(start), "to": str(end)},
)
if max_days is not None and (end - start).days + 1 > max_days:
raise DomainValidationError(
f"La période demandée est trop longue (maximum {max_days} jours).",
details={"from": str(start), "to": str(end), "max_days": max_days},
)
return start, end
def today_local(tz: ZoneInfo) -> dt.date:
return utcnow().astimezone(tz).date()
def profile_tz(db: Session, user_id: int, tz: str | None) -> ZoneInfo:
"""Explicit ?tz= wins, else the profile timezone, else the app default."""
if tz:
return resolve_tz(tz)
profile = get_profile(db, user_id)
return resolve_tz(profile.timezone if profile else None)
# --- Profile ------------------------------------------------------------------
def get_profile(db: Session, user_id: int) -> HealthProfile | None:
return db.scalar(select(HealthProfile).where(HealthProfile.user_id == user_id))
def require_profile(db: Session, user_id: int) -> HealthProfile:
profile = get_profile(db, user_id)
if profile is None:
raise NotFoundError(
"Profil santé non configuré. Renseignez taille, sexe et date de naissance."
)
return profile
def upsert_profile(
db: Session, user_id: int, payload: HealthProfileUpdate
) -> HealthProfile:
resolve_tz(payload.timezone) # validates the IANA name (French error otherwise)
profile = get_profile(db, user_id)
if profile is None:
profile = HealthProfile(user_id=user_id)
db.add(profile)
profile.height_cm = _dec(payload.height_cm)
profile.sex = payload.sex
profile.birthdate = payload.birthdate
profile.activity_level = payload.activity_level
profile.timezone = payload.timezone
profile.water_goal_ml = payload.water_goal_ml
profile.calorie_floor_kcal = payload.calorie_floor_kcal
db.commit()
db.refresh(profile)
return profile
# --- Weight entries -----------------------------------------------------------
WEIGHT_SORTS = {
"measured_at": WeightEntry.measured_at,
"weight_kg": WeightEntry.weight_kg,
}
def weights_query(
user_id: int,
from_: dt.date | None,
to: dt.date | None,
tz: ZoneInfo,
sort: str | None,
) -> Select:
stmt = select(WeightEntry).where(WeightEntry.user_id == user_id)
if from_ or to:
start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None)
begin, stop = utc_window(start, end, tz)
stmt = stmt.where(
WeightEntry.measured_at >= begin, WeightEntry.measured_at < stop
)
return _apply_sort(stmt, sort, WEIGHT_SORTS, "-measured_at")
def create_weight(db: Session, user_id: int, payload: WeightEntryCreate) -> WeightEntry:
existing = db.scalar(
select(WeightEntry).where(
WeightEntry.user_id == user_id,
WeightEntry.measured_at == payload.measured_at,
WeightEntry.source == MANUAL_SOURCE,
)
)
if existing is not None:
raise ConflictError("Une pesée existe déjà à cet horodatage.")
entry = WeightEntry(
user_id=user_id,
source=MANUAL_SOURCE,
measured_at=to_utc(payload.measured_at),
weight_kg=_dec(payload.weight_kg),
body_fat_pct=_dec(payload.body_fat_pct),
muscle_mass_kg=_dec(payload.muscle_mass_kg),
water_pct=_dec(payload.water_pct),
note=payload.note,
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def get_weight(db: Session, user_id: int, entry_id: int) -> WeightEntry:
entry = db.get(WeightEntry, entry_id)
if entry is None or entry.user_id != user_id:
raise NotFoundError("Pesée introuvable.")
return entry
def update_weight(
db: Session, user_id: int, entry_id: int, payload: WeightEntryUpdate
) -> WeightEntry:
entry = get_weight(db, user_id, entry_id)
data = payload.model_dump(exclude_unset=True)
for field, value in data.items():
if field in {"weight_kg", "body_fat_pct", "muscle_mass_kg", "water_pct"}:
value = _dec(value)
elif field in _DATETIME_FIELDS:
value = to_utc(value)
setattr(entry, field, value)
db.commit()
db.refresh(entry)
return entry
def delete_weight(db: Session, user_id: int, entry_id: int) -> None:
db.delete(get_weight(db, user_id, entry_id))
db.commit()
def daily_weights(
db: Session,
user_id: int,
tz: ZoneInfo,
until: dt.date | None = None,
) -> list[tuple[dt.date, float]]:
"""One point per local day = FIRST weigh-in of the day (§5.5)."""
stmt = select(WeightEntry).where(WeightEntry.user_id == user_id)
if until is not None:
_, stop = utc_window(until, until, tz)
stmt = stmt.where(WeightEntry.measured_at < stop)
rows = db.scalars(stmt.order_by(WeightEntry.measured_at.asc())).all()
per_day: dict[dt.date, float] = {}
for row in rows:
day = local_day_of(row.measured_at, tz)
if day not in per_day: # rows are ascending -> first of the day wins
per_day[day] = float(row.weight_kg)
return sorted(per_day.items())
# --- Body measurements --------------------------------------------------------
MEASUREMENT_SORTS = {"measured_at": BodyMeasurement.measured_at}
def measurements_query(
user_id: int,
from_: dt.date | None,
to: dt.date | None,
tz: ZoneInfo,
sort: str | None,
) -> Select:
stmt = select(BodyMeasurement).where(BodyMeasurement.user_id == user_id)
if from_ or to:
start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None)
begin, stop = utc_window(start, end, tz)
stmt = stmt.where(
BodyMeasurement.measured_at >= begin, BodyMeasurement.measured_at < stop
)
return _apply_sort(stmt, sort, MEASUREMENT_SORTS, "-measured_at")
def create_measurement(
db: Session, user_id: int, payload: BodyMeasurementCreate
) -> BodyMeasurement:
data = payload.model_dump()
note = data.pop("note", None)
measured_at = data.pop("measured_at")
row = BodyMeasurement(
user_id=user_id,
source=MANUAL_SOURCE,
measured_at=to_utc(measured_at),
note=note,
**{key: _dec(value) for key, value in data.items()},
)
db.add(row)
db.commit()
db.refresh(row)
return row
def get_measurement(db: Session, user_id: int, row_id: int) -> BodyMeasurement:
row = db.get(BodyMeasurement, row_id)
if row is None or row.user_id != user_id:
raise NotFoundError("Mensuration introuvable.")
return row
def update_measurement(
db: Session, user_id: int, row_id: int, payload: BodyMeasurementUpdate
) -> BodyMeasurement:
row = get_measurement(db, user_id, row_id)
for field, value in payload.model_dump(exclude_unset=True).items():
if field.endswith("_cm"):
value = _dec(value)
elif field in _DATETIME_FIELDS:
value = to_utc(value)
setattr(row, field, value)
db.commit()
db.refresh(row)
return row
def delete_measurement(db: Session, user_id: int, row_id: int) -> None:
db.delete(get_measurement(db, user_id, row_id))
db.commit()
def measurements_between(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> list[BodyMeasurement]:
begin, stop = utc_window(start, end, tz)
return list(
db.scalars(
select(BodyMeasurement)
.where(
BodyMeasurement.user_id == user_id,
BodyMeasurement.measured_at >= begin,
BodyMeasurement.measured_at < stop,
)
.order_by(BodyMeasurement.measured_at.asc())
).all()
)
# --- Daily activity -----------------------------------------------------------
def activity_rows(
db: Session, user_id: int, start: dt.date, end: dt.date
) -> list[DailyActivity]:
return list(
db.scalars(
select(DailyActivity)
.where(
DailyActivity.user_id == user_id,
DailyActivity.date >= start,
DailyActivity.date <= end,
)
.order_by(DailyActivity.date.asc())
).all()
)
def merged_activity(
db: Session, user_id: int, start: dt.date, end: dt.date
) -> dict[dt.date, calc.MergedActivity]:
"""Field-by-field cross-source merge, one entry per day with data (§3.5)."""
per_day: dict[dt.date, list[DailyActivity]] = {}
for row in activity_rows(db, user_id, start, end):
per_day.setdefault(row.date, []).append(row)
return {day: calc.merge_activity_day(rows) for day, rows in per_day.items()}
def upsert_manual_activity(
db: Session, user_id: int, payload: DailyActivityUpsert
) -> DailyActivity:
row = db.scalar(
select(DailyActivity).where(
DailyActivity.user_id == user_id,
DailyActivity.date == payload.date,
DailyActivity.source == MANUAL_SOURCE,
)
)
if row is None:
row = DailyActivity(user_id=user_id, date=payload.date, source=MANUAL_SOURCE)
db.add(row)
row.steps = payload.steps
row.active_kcal = _dec(payload.active_kcal)
row.total_kcal = _dec(payload.total_kcal)
row.distance_m = payload.distance_m
row.active_minutes = payload.active_minutes
row.floors = payload.floors
db.commit()
db.refresh(row)
return row
def delete_activity(db: Session, user_id: int, row_id: int) -> None:
row = db.get(DailyActivity, row_id)
if row is None or row.user_id != user_id:
raise NotFoundError("Journée d'activité introuvable.")
db.delete(row)
db.commit()
# --- Workouts -----------------------------------------------------------------
WORKOUT_SORTS = {"started_at": Workout.started_at, "kcal": Workout.kcal}
def workouts_query(
user_id: int,
from_: dt.date | None,
to: dt.date | None,
tz: ZoneInfo,
sport_type: str | None,
include_hidden: bool,
sort: str | None,
) -> Select:
stmt = select(Workout).where(Workout.user_id == user_id)
if not include_hidden:
stmt = stmt.where(Workout.is_hidden.is_(False))
if sport_type:
stmt = stmt.where(Workout.sport_type == sport_type)
if from_ or to:
start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None)
begin, stop = utc_window(start, end, tz)
stmt = stmt.where(Workout.started_at >= begin, Workout.started_at < stop)
return _apply_sort(stmt, sort, WORKOUT_SORTS, "-started_at")
def flag_overlapping_duplicates(db: Session, workout: Workout) -> None:
"""Cross-source dedup (§3.6): >= 80 % overlap hides the lower-priority row."""
others = db.scalars(
select(Workout).where(
Workout.user_id == workout.user_id,
Workout.id != workout.id,
Workout.source != workout.source,
Workout.is_hidden.is_(False),
Workout.started_at < workout.ended_at,
Workout.ended_at > workout.started_at,
)
).all()
for other in others:
ratio = calc.overlap_ratio(
to_utc(workout.started_at),
to_utc(workout.ended_at),
to_utc(other.started_at),
to_utc(other.ended_at),
)
if ratio < calc.WORKOUT_OVERLAP_THRESHOLD:
continue
if calc.source_priority(workout.source) <= calc.source_priority(other.source):
other.is_hidden = True
else:
workout.is_hidden = True
def create_workout(db: Session, user_id: int, payload: WorkoutCreate) -> Workout:
if payload.ended_at <= payload.started_at:
raise DomainValidationError("La fin de séance doit suivre le début.")
workout = Workout(
user_id=user_id,
source=MANUAL_SOURCE,
started_at=to_utc(payload.started_at),
ended_at=to_utc(payload.ended_at),
sport_type=payload.sport_type,
sport_label=payload.sport_label,
kcal=_dec(payload.kcal),
distance_m=payload.distance_m,
steps=payload.steps,
avg_hr=payload.avg_hr,
max_hr=payload.max_hr,
avg_speed_kmh=_dec(payload.avg_speed_kmh),
elevation_m=payload.elevation_m,
note=payload.note,
)
db.add(workout)
db.flush()
flag_overlapping_duplicates(db, workout)
db.commit()
db.refresh(workout)
return workout
def get_workout(db: Session, user_id: int, workout_id: int) -> Workout:
workout = db.get(Workout, workout_id)
if workout is None or workout.user_id != user_id:
raise NotFoundError("Séance introuvable.")
return workout
def update_workout(
db: Session, user_id: int, workout_id: int, payload: WorkoutUpdate
) -> Workout:
workout = get_workout(db, user_id, workout_id)
for field, value in payload.model_dump(exclude_unset=True).items():
if field in {"kcal", "avg_speed_kmh"}:
value = _dec(value)
elif field in _DATETIME_FIELDS:
value = to_utc(value)
setattr(workout, field, value)
if workout.ended_at <= workout.started_at:
raise DomainValidationError("La fin de séance doit suivre le début.")
db.commit()
db.refresh(workout)
return workout
def delete_workout(db: Session, user_id: int, workout_id: int) -> None:
db.delete(get_workout(db, user_id, workout_id))
db.commit()
def workout_duration_s(workout: Workout) -> int:
return workout.duration_s
# --- Goals --------------------------------------------------------------------
def goals_query(user_id: int, status: str | None, sort: str | None) -> Select:
stmt = select(Goal).where(Goal.user_id == user_id)
if status:
stmt = stmt.where(Goal.status == status)
return _apply_sort(
stmt, sort, {"start_date": Goal.start_date, "id": Goal.id}, "-start_date"
)
def active_goal(db: Session, user_id: int) -> Goal | None:
return db.scalar(
select(Goal).where(Goal.user_id == user_id, Goal.status == GoalStatus.ACTIVE)
)
def create_goal(
db: Session, user_id: int, payload: GoalCreate, replace_active: bool
) -> Goal:
if payload.mode == "target_date" and payload.target_date is None:
raise DomainValidationError("Une date cible est requise pour ce mode.")
if payload.mode == "weekly_rate" and payload.weekly_rate_kg is None:
raise DomainValidationError("Un rythme hebdomadaire est requis pour ce mode.")
current = active_goal(db, user_id)
if current is not None:
if not replace_active:
raise ConflictError(
"Un objectif est déjà actif. Terminez-le ou utilisez « remplacer »."
)
current.status = GoalStatus.ABANDONED
db.flush()
tz = resolve_tz(
get_profile(db, user_id).timezone if get_profile(db, user_id) else None
)
start_date = payload.start_date or today_local(tz)
start_weight = payload.start_weight_kg
if start_weight is None:
trend = calc.weight_trend(daily_weights(db, user_id, tz))
start_weight = trend[-1][1] if trend else None
if start_weight is None:
raise DomainValidationError(
"Poids de départ inconnu : ajoutez une pesée ou renseignez-le."
)
goal = Goal(
user_id=user_id,
mode=payload.mode,
start_date=start_date,
start_weight_kg=_dec(start_weight),
target_weight_kg=_dec(payload.target_weight_kg),
target_date=payload.target_date,
weekly_rate_kg=_dec(payload.weekly_rate_kg),
status=GoalStatus.ACTIVE,
note=payload.note,
)
db.add(goal)
db.commit()
db.refresh(goal)
return goal
def get_goal(db: Session, user_id: int, goal_id: int) -> Goal:
goal = db.get(Goal, goal_id)
if goal is None or goal.user_id != user_id:
raise NotFoundError("Objectif introuvable.")
return goal
def update_goal(db: Session, user_id: int, goal_id: int, payload: GoalUpdate) -> Goal:
goal = get_goal(db, user_id, goal_id)
data = payload.model_dump(exclude_unset=True)
if data.get("status") == GoalStatus.ACTIVE and goal.status != GoalStatus.ACTIVE:
other = active_goal(db, user_id)
if other is not None and other.id != goal.id:
raise ConflictError("Un autre objectif est déjà actif.")
for field, value in data.items():
if field in {"start_weight_kg", "target_weight_kg", "weekly_rate_kg"}:
value = _dec(value)
setattr(goal, field, value)
if goal.mode == "target_date" and goal.target_date is None:
raise DomainValidationError("Une date cible est requise pour ce mode.")
if goal.mode == "weekly_rate" and goal.weekly_rate_kg is None:
raise DomainValidationError("Un rythme hebdomadaire est requis pour ce mode.")
db.commit()
db.refresh(goal)
return goal
def activate_goal(db: Session, user_id: int, goal_id: int) -> Goal:
goal = get_goal(db, user_id, goal_id)
current = active_goal(db, user_id)
if current is not None and current.id != goal.id:
current.status = GoalStatus.ABANDONED
db.flush()
goal.status = GoalStatus.ACTIVE
db.commit()
db.refresh(goal)
return goal
def delete_goal(db: Session, user_id: int, goal_id: int) -> None:
db.delete(get_goal(db, user_id, goal_id))
db.commit()
# --- Food entries -------------------------------------------------------------
FOOD_SORTS = {"eaten_at": FoodEntry.eaten_at, "kcal": FoodEntry.kcal}
_FOOD_MACROS = (
"quantity",
"kcal",
"protein_g",
"carbs_g",
"fat_g",
"fiber_g",
"sugar_g",
"sat_fat_g",
"sodium_mg",
)
def food_entries_query(
user_id: int,
tz: ZoneInfo,
day: dt.date | None,
from_: dt.date | None,
to: dt.date | None,
meal: str | None,
q: str | None,
sort: str | None,
) -> Select:
stmt = select(FoodEntry).where(FoodEntry.user_id == user_id)
if day is not None:
from_, to = day, day
if from_ or to:
start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None)
begin, stop = utc_window(start, end, tz)
stmt = stmt.where(FoodEntry.eaten_at >= begin, FoodEntry.eaten_at < stop)
if meal:
stmt = stmt.where(FoodEntry.meal == meal)
if q:
pattern = f"%{q.lower()}%"
stmt = stmt.where(
or_(
FoodEntry.name.ilike(pattern),
FoodEntry.brand.ilike(pattern),
)
)
return _apply_sort(stmt, sort, FOOD_SORTS, "-eaten_at")
def create_food_entry(db: Session, user_id: int, payload: FoodEntryCreate) -> FoodEntry:
data = payload.model_dump()
favorite_id = data.pop("favorite_id", None)
if favorite_id is not None:
favorite = get_favorite(db, user_id, favorite_id)
# The favorite is the source of truth for identity and macros (§4.2):
# only the eaten quantity comes from the payload, macros are prorated.
ratio = float(payload.quantity) / float(favorite.default_quantity or 1)
data["name"] = favorite.name
data["brand"] = favorite.brand
data["unit"] = favorite.unit
data["kcal"] = float(favorite.kcal) * ratio
for macro in ("protein_g", "carbs_g", "fat_g", "fiber_g"):
value = getattr(favorite, macro)
data[macro] = float(value) * ratio if value is not None else None
favorite.use_count += 1
favorite.last_used_at = utcnow()
entry = FoodEntry(
user_id=user_id,
source=MANUAL_SOURCE,
eaten_at=to_utc(data["eaten_at"]),
meal=data["meal"],
name=data["name"],
brand=data["brand"],
unit=data["unit"],
**{key: _dec(data[key]) for key in _FOOD_MACROS},
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def get_food_entry(db: Session, user_id: int, entry_id: int) -> FoodEntry:
entry = db.get(FoodEntry, entry_id)
if entry is None or entry.user_id != user_id:
raise NotFoundError("Entrée alimentaire introuvable.")
return entry
def update_food_entry(
db: Session, user_id: int, entry_id: int, payload: FoodEntryUpdate
) -> FoodEntry:
entry = get_food_entry(db, user_id, entry_id)
for field, value in payload.model_dump(exclude_unset=True).items():
if field in _FOOD_MACROS:
value = _dec(value)
elif field in _DATETIME_FIELDS:
value = to_utc(value)
setattr(entry, field, value)
db.commit()
db.refresh(entry)
return entry
def delete_food_entry(db: Session, user_id: int, entry_id: int) -> None:
db.delete(get_food_entry(db, user_id, entry_id))
db.commit()
def food_entries_between(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> list[FoodEntry]:
begin, stop = utc_window(start, end, tz)
return list(
db.scalars(
select(FoodEntry)
.where(
FoodEntry.user_id == user_id,
FoodEntry.eaten_at >= begin,
FoodEntry.eaten_at < stop,
)
.order_by(FoodEntry.eaten_at.asc())
).all()
)
def recent_foods(db: Session, user_id: int, limit: int = 20) -> list[FoodEntry]:
"""Distinct (name, brand) most recently logged (§8.3 /nutrition/recent)."""
rows = db.scalars(
select(FoodEntry)
.where(FoodEntry.user_id == user_id)
.order_by(FoodEntry.eaten_at.desc())
.limit(limit * 10)
).all()
seen: set[tuple[str, str | None]] = set()
out: list[FoodEntry] = []
for row in rows:
key = (row.name.strip().lower(), (row.brand or "").strip().lower() or None)
if key in seen:
continue
seen.add(key)
out.append(row)
if len(out) >= limit:
break
return out
# --- Favorites ----------------------------------------------------------------
def favorites_query(user_id: int, sort: str | None) -> Select:
stmt = select(FoodFavorite).where(FoodFavorite.user_id == user_id)
return _apply_sort(
stmt,
sort,
{
"use_count": FoodFavorite.use_count,
"name": FoodFavorite.name,
"last_used_at": FoodFavorite.last_used_at,
},
"-use_count",
)
def create_favorite(
db: Session, user_id: int, payload: FoodFavoriteCreate
) -> FoodFavorite:
existing = db.scalar(
select(FoodFavorite).where(
FoodFavorite.user_id == user_id,
FoodFavorite.name == payload.name,
FoodFavorite.brand.is_(None)
if payload.brand is None
else FoodFavorite.brand == payload.brand,
)
)
if existing is not None:
raise ConflictError("Cet aliment favori existe déjà.")
favorite = FoodFavorite(
user_id=user_id,
name=payload.name,
brand=payload.brand,
unit=payload.unit,
default_meal=payload.default_meal,
default_quantity=_dec(payload.default_quantity),
kcal=_dec(payload.kcal),
protein_g=_dec(payload.protein_g),
carbs_g=_dec(payload.carbs_g),
fat_g=_dec(payload.fat_g),
fiber_g=_dec(payload.fiber_g),
)
db.add(favorite)
db.commit()
db.refresh(favorite)
return favorite
def get_favorite(db: Session, user_id: int, favorite_id: int) -> FoodFavorite:
favorite = db.get(FoodFavorite, favorite_id)
if favorite is None or favorite.user_id != user_id:
raise NotFoundError("Aliment favori introuvable.")
return favorite
def update_favorite(
db: Session, user_id: int, favorite_id: int, payload: FoodFavoriteUpdate
) -> FoodFavorite:
favorite = get_favorite(db, user_id, favorite_id)
for field, value in payload.model_dump(exclude_unset=True).items():
if field in {
"default_quantity",
"kcal",
"protein_g",
"carbs_g",
"fat_g",
"fiber_g",
}:
value = _dec(value)
setattr(favorite, field, value)
db.commit()
db.refresh(favorite)
return favorite
def delete_favorite(db: Session, user_id: int, favorite_id: int) -> None:
db.delete(get_favorite(db, user_id, favorite_id))
db.commit()
# --- Water --------------------------------------------------------------------
def water_query(
user_id: int,
from_: dt.date | None,
to: dt.date | None,
tz: ZoneInfo,
sort: str | None,
) -> Select:
stmt = select(WaterEntry).where(WaterEntry.user_id == user_id)
if from_ or to:
start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None)
begin, stop = utc_window(start, end, tz)
stmt = stmt.where(WaterEntry.drunk_at >= begin, WaterEntry.drunk_at < stop)
return _apply_sort(stmt, sort, {"drunk_at": WaterEntry.drunk_at}, "-drunk_at")
def create_water(db: Session, user_id: int, payload: WaterEntryCreate) -> WaterEntry:
entry = WaterEntry(
user_id=user_id,
source=MANUAL_SOURCE,
drunk_at=to_utc(payload.drunk_at),
volume_ml=payload.volume_ml,
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def delete_water(db: Session, user_id: int, entry_id: int) -> None:
entry = db.get(WaterEntry, entry_id)
if entry is None or entry.user_id != user_id:
raise NotFoundError("Entrée d'hydratation introuvable.")
db.delete(entry)
db.commit()
def water_between(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> list[WaterEntry]:
begin, stop = utc_window(start, end, tz)
return list(
db.scalars(
select(WaterEntry).where(
WaterEntry.user_id == user_id,
WaterEntry.drunk_at >= begin,
WaterEntry.drunk_at < stop,
)
).all()
)
# --- Planning (addendum-planning.md) ------------------------------------------
def list_schedules(db: Session, user_id: int) -> list[TrackingSchedule]:
"""The three habits; missing ones are returned disabled with no weekday."""
rows = {
row.kind: row
for row in db.scalars(
select(TrackingSchedule).where(TrackingSchedule.user_id == user_id)
).all()
}
out: list[TrackingSchedule] = []
for kind in calc.SCHEDULE_KINDS:
existing = rows.get(ScheduleKind(kind))
out.append(
existing
if existing is not None
else TrackingSchedule(
user_id=user_id,
kind=ScheduleKind(kind),
weekdays=list(DEFAULT_WEEKDAYS),
enabled=False,
)
)
return out
def upsert_schedule(
db: Session, user_id: int, kind: ScheduleKind, payload: ScheduleUpdate
) -> TrackingSchedule:
weekdays = sorted({int(day) for day in payload.weekdays})
if any(day < 0 or day > 6 for day in weekdays):
raise DomainValidationError(
"Les jours doivent être compris entre 0 (lundi) et 6 (dimanche)."
)
row = db.scalar(
select(TrackingSchedule).where(
TrackingSchedule.user_id == user_id, TrackingSchedule.kind == kind
)
)
if row is None:
row = TrackingSchedule(user_id=user_id, kind=kind)
db.add(row)
row.weekdays = weekdays
row.enabled = payload.enabled
db.commit()
db.refresh(row)
return row
def done_days_by_kind(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> dict[str, dict[dt.date, float]]:
"""Derived "done" days + the value shown by the UI, per habit kind.
weigh_in -> first weight of the day (kg); workout -> session count;
food_log -> total kcal logged that day.
"""
begin, stop = utc_window(start, end, tz)
out: dict[str, dict[dt.date, float]] = {
"weigh_in": {},
"workout": {},
"food_log": {},
}
weights = db.scalars(
select(WeightEntry)
.where(
WeightEntry.user_id == user_id,
WeightEntry.measured_at >= begin,
WeightEntry.measured_at < stop,
)
.order_by(WeightEntry.measured_at.asc())
).all()
for row in weights:
day = local_day_of(row.measured_at, tz)
out["weigh_in"].setdefault(day, float(row.weight_kg))
workouts = db.scalars(
select(Workout).where(
Workout.user_id == user_id,
Workout.is_hidden.is_(False),
Workout.started_at >= begin,
Workout.started_at < stop,
)
).all()
for workout in workouts:
day = local_day_of(workout.started_at, tz)
out["workout"][day] = out["workout"].get(day, 0.0) + 1
for entry in food_entries_between(db, user_id, start, end, tz):
day = local_day_of(entry.eaten_at, tz)
out["food_log"][day] = out["food_log"].get(day, 0.0) + float(entry.kcal)
return out
def schedule_map(db: Session, user_id: int) -> dict[str, TrackingSchedule]:
return {str(row.kind.value): row for row in list_schedules(db, user_id)}
# --- Cross-cutting aggregation helpers (used by stats.py) ---------------------
def intake_by_day(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> dict[dt.date, dict[str, float]]:
"""Per local day nutritional totals; days without any entry are absent."""
totals: dict[dt.date, dict[str, float]] = {}
for entry in food_entries_between(db, user_id, start, end, tz):
day = local_day_of(entry.eaten_at, tz)
bucket = totals.setdefault(
day,
{
"kcal": 0.0,
"protein_g": 0.0,
"carbs_g": 0.0,
"fat_g": 0.0,
"fiber_g": 0.0,
},
)
bucket["kcal"] += float(entry.kcal)
for macro in ("protein_g", "carbs_g", "fat_g", "fiber_g"):
value = getattr(entry, macro)
if value is not None:
bucket[macro] += float(value)
return totals
def meal_kcal_by_day(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> dict[dt.date, dict[str, float]]:
per_day: dict[dt.date, dict[str, float]] = {}
for entry in food_entries_between(db, user_id, start, end, tz):
day = local_day_of(entry.eaten_at, tz)
bucket = per_day.setdefault(day, {meal.value: 0.0 for meal in MealType})
bucket[entry.meal.value] += float(entry.kcal)
return per_day
def top_foods(
db: Session,
user_id: int,
start: dt.date,
end: dt.date,
tz: ZoneInfo,
limit: int = 10,
) -> list[dict]:
stats: dict[str, dict] = {}
for entry in food_entries_between(db, user_id, start, end, tz):
key = entry.name.strip()
bucket = stats.setdefault(key, {"name": key, "kcal": 0.0, "count": 0})
bucket["kcal"] += float(entry.kcal)
bucket["count"] += 1
ranked = sorted(stats.values(), key=lambda item: item["kcal"], reverse=True)
return ranked[:limit]
def workouts_between(
db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo
) -> list[Workout]:
begin, stop = utc_window(start, end, tz)
return list(
db.scalars(
select(Workout)
.where(
Workout.user_id == user_id,
Workout.is_hidden.is_(False),
and_(Workout.started_at >= begin, Workout.started_at < stop),
)
.order_by(Workout.started_at.asc())
).all()
)