TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection

This commit is contained in:
Craig
2026-07-26 17:27:15 +01:00
parent 4dd44b08d0
commit a8aed9a84f
9 changed files with 1720 additions and 73 deletions
+120 -24
View File
@@ -12,15 +12,16 @@ ORM objects never leave this module; functions return Pydantic schemas.
from datetime import date, datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from models import DailyLogEntry, Food
from models import DailyLogEntry, Food, MealComponent
from schemas import (
DaySummaryNutrition,
DaySummaryResponse,
LogEntryCreate,
LogEntryRead,
LogEntryUpdate,
MealComponentRead,
)
from services import nutrition
from services import targets as targets_svc
@@ -39,20 +40,82 @@ def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
)
def _build_log_food_dict(food: Food) -> dict:
"""Convert a Food ORM to dict for LogFoodRead, including nested components
when the food is a meal."""
data = {
"id": food.id,
"name": food.name,
"brand": food.brand,
"unit_type": food.unit_type,
"calories_per_unit": food.calories_per_unit,
"serving_size_g": food.serving_size_g,
"serving_name": food.serving_name,
"is_meal": food.is_meal,
"deleted_at": food.deleted_at,
}
if food.is_meal and hasattr(food, "components"):
comps = []
for mc in food.components:
cf = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": cf.id,
"name": cf.name,
"brand": cf.brand,
"unit_type": cf.unit_type,
"calories_per_unit": cf.calories_per_unit,
"serving_size_g": cf.serving_size_g,
"serving_name": cf.serving_name,
"is_meal": cf.is_meal,
"deleted_at": cf.deleted_at,
},
))
data["components"] = comps
else:
data["components"] = None
return data
# ── CRUD ─────────────────────────────────────────────────────────────────────
def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]:
"""All entries for a date, ordered by sort_order then id, with embedded
food data eagerly loaded. Soft-deleted foods still render (§2.1)."""
food data eagerly loaded. Meal foods include their nested components
so the frontend can render collapsible rows (§3.3).
Soft-deleted foods still render (§2.1).
"""
stmt = (
select(DailyLogEntry)
.where(DailyLogEntry.date == lookup_date)
.options(joinedload(DailyLogEntry.food))
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
)
entries = db.scalars(stmt).all()
return [LogEntryRead.model_validate(e) for e in entries]
entries = db.scalars(stmt).unique().all()
result: list[LogEntryRead] = []
for entry in entries:
food_data = _build_log_food_dict(entry.food)
# Compute nutrition for this entry (handles meals recursively)
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
result.append(LogEntryRead.model_validate({
"id": entry.id,
"date": entry.date,
"food_id": entry.food_id,
"quantity": entry.quantity,
"meal_slot": entry.meal_slot,
"sort_order": entry.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
}))
return result
def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
@@ -87,7 +150,19 @@ def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
db.commit()
# Re-query with eager-loaded food for the response
return LogEntryRead.model_validate(_load_entry_with_food(db, entry.id))
loaded = _load_entry_with_food(db, entry.id)
food_data = _build_log_food_dict(loaded.food)
entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity)
return LogEntryRead.model_validate({
"id": loaded.id,
"date": loaded.date,
"food_id": loaded.food_id,
"quantity": loaded.quantity,
"meal_slot": loaded.meal_slot,
"sort_order": loaded.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
})
def update_log_entry(
@@ -109,7 +184,29 @@ def update_log_entry(
setattr(entry, field, value)
db.commit()
return LogEntryRead.model_validate(_load_entry_with_food(db, entry_id))
# Re-query with eager-loaded food + components for the response
loaded = db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
food_data = _build_log_food_dict(loaded.food)
entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity)
return LogEntryRead.model_validate({
"id": loaded.id,
"date": loaded.date,
"food_id": loaded.food_id,
"quantity": loaded.quantity,
"meal_slot": loaded.meal_slot,
"sort_order": loaded.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
})
def delete_log_entry(db: Session, entry_id: int) -> bool:
@@ -141,14 +238,12 @@ class FoodNotAvailableError(Exception):
def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
"""Compute nutrition totals for a date vs. the applicable target.
1. Loads all log entries for the date with food data eagerly joined.
2. For each entry, scales the food's per-unit nutrition to the logged
quantity via nutrition.entry_nutrition() — weight-type foods get
(qty/100)× scaling, count-type foods get qty× scaling (§2.1).
3. Meal entries (is_meal=True) currently contribute 0 because their
per_unit fields are null per the CHECK constraint.
TODO: TICKET-007 — replace with recursive component summation.
4. Looks up the target covering the date via the half-open interval
1. Loads all log entries for the date with food data and meal components
eagerly joined.
2. For each entry, uses nutrition.entry_nutrition() which now handles
meal foods recursively by summing their component nutrition (§2.2).
Cycle detection prevents infinite loops on inconsistent data.
3. Looks up the target covering the date via the half-open interval
lookup from targets.get_target_for_date(). Returns null if none.
Returns a DaySummaryResponse with summed totals and the applicable
@@ -157,15 +252,16 @@ def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
entries = db.scalars(
select(DailyLogEntry)
.where(DailyLogEntry.date == lookup_date)
.options(joinedload(DailyLogEntry.food))
).all()
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
).unique().all()
# Sum nutrition across all entries for the date.
# TODO: TICKET-007 — meal entries currently contribute 0 because their
# per_unit fields are null per the CHECK constraint. Real meal nutrition
# will be derived by recursively summing component foods' nutrition.
# When that lands, replace the flat entry_nutrition() call with a
# meal-aware sum function from services/nutrition.py.
# entry_nutrition() now handles meal foods by recursive component
# summation (TICKET-007).
totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS}
for entry in entries:
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
@@ -178,4 +274,4 @@ def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
date=lookup_date,
totals=DaySummaryNutrition(**totals),
target=target,
)
)