TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection
This commit is contained in:
+120
-24
@@ -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,
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user