"""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 latest_weight( db: Session, user_id: int, tz: ZoneInfo, until: dt.date | None = None ) -> WeightEntry | None: """Most recent weigh-in (<= end of the local day `until`), or None.""" 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) return db.scalar(stmt.order_by(WeightEntry.measured_at.desc()).limit(1)) 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() )