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>
1094 lines
35 KiB
Python
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()
|
|
)
|