TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
ORM objects never leave this module; functions return Pydantic schemas.
|
||||
Handlers stay thin (~15 lines) by calling into these functions.
|
||||
|
||||
Meal foods are returned as MealRead (with components + computed nutrition)
|
||||
via get_meal(). get_food() returns the appropriate type based on is_meal.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
@@ -10,7 +13,8 @@ from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import DailyLogEntry, Food
|
||||
from schemas import FoodCreate, FoodRead, FoodUpdate
|
||||
from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead
|
||||
from services.meals import _build_meal_read, load_meal_with_components
|
||||
|
||||
|
||||
# ── Shared query helpers (§8.1 rule 7) ───────────────────────────────────────
|
||||
@@ -56,13 +60,32 @@ def list_foods(
|
||||
return [FoodRead.model_validate(f) for f in foods]
|
||||
|
||||
|
||||
def get_food(db: Session, food_id: int) -> FoodRead | None:
|
||||
def get_food(db: Session, food_id: int) -> FoodRead | MealRead | None:
|
||||
"""Get a single food by id. INCLUDES soft-deleted foods (historical log
|
||||
rendering depends on this — spec §2.1). Returns None for unknown id."""
|
||||
food = db.get(Food, food_id)
|
||||
rendering depends on this — spec §2.1).
|
||||
|
||||
Returns MealRead (with components + computed nutrition) for meal foods,
|
||||
plain FoodRead for non-meals. Returns None for unknown id.
|
||||
"""
|
||||
food = load_meal_with_components(db, food_id)
|
||||
if food is None:
|
||||
# Not a meal — try as a plain food
|
||||
food = db.get(Food, food_id)
|
||||
if food is None:
|
||||
return None
|
||||
return FoodRead.model_validate(food)
|
||||
# Meal food: return MealRead with components
|
||||
return _build_meal_read(food)
|
||||
|
||||
|
||||
def get_meal(db: Session, meal_id: int) -> MealRead | None:
|
||||
"""Get a meal food with components and computed nutrition.
|
||||
|
||||
Returns None if the food doesn't exist or is not a meal."""
|
||||
meal = load_meal_with_components(db, meal_id)
|
||||
if meal is None:
|
||||
return None
|
||||
return FoodRead.model_validate(food)
|
||||
return _build_meal_read(meal)
|
||||
|
||||
|
||||
def create_food(db: Session, data: FoodCreate) -> FoodRead:
|
||||
|
||||
+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,
|
||||
)
|
||||
)
|
||||
+546
-2
@@ -1,7 +1,551 @@
|
||||
"""Meal business logic: composition, recursion, cycle detection (spec §2.2).
|
||||
|
||||
Multi-write operations here own their transactions (spec §8.1 rule 6):
|
||||
commit once at the end or roll back entirely.
|
||||
create_meal_from_log, unpack_meal, and update_meal_components each commit once
|
||||
at the end or roll back entirely — never partial writes.
|
||||
|
||||
TODO: from-log, unpack, component replacement with cycle checks.
|
||||
ORM objects never leave this module; functions return Pydantic schemas
|
||||
(spec §8.1 rule 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from models import DailyLogEntry, Food, MealComponent
|
||||
from schemas import (
|
||||
FoodRead,
|
||||
LogEntryRead,
|
||||
MealComponentRead,
|
||||
MealRead,
|
||||
)
|
||||
from services import nutrition
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
|
||||
"""Eager-load a single entry with its food relationship."""
|
||||
return db.scalar(
|
||||
select(DailyLogEntry)
|
||||
.where(DailyLogEntry.id == entry_id)
|
||||
.options(joinedload(DailyLogEntry.food))
|
||||
)
|
||||
|
||||
|
||||
def _load_entry_with_food_components(db: Session, entry_id: int) -> DailyLogEntry | None:
|
||||
"""Eager-load a single entry with food and nested meal components."""
|
||||
return db.scalar(
|
||||
select(DailyLogEntry)
|
||||
.where(DailyLogEntry.id == entry_id)
|
||||
.options(
|
||||
joinedload(DailyLogEntry.food)
|
||||
.selectinload(Food.components)
|
||||
.joinedload(MealComponent.food)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _build_log_entry_read(entry: DailyLogEntry) -> LogEntryRead:
|
||||
"""Convert a DailyLogEntry ORM object to LogEntryRead, populating
|
||||
nested meal components on the embedded food when is_meal is True."""
|
||||
data = {
|
||||
"id": entry.id,
|
||||
"date": entry.date,
|
||||
"food_id": entry.food_id,
|
||||
"quantity": entry.quantity,
|
||||
"meal_slot": entry.meal_slot,
|
||||
"sort_order": entry.sort_order,
|
||||
}
|
||||
# Build the embedded food
|
||||
food = entry.food
|
||||
food_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,
|
||||
}
|
||||
# Populate nested components for meal foods
|
||||
if food.is_meal and hasattr(food, "components"):
|
||||
comps = []
|
||||
for mc in food.components:
|
||||
comp_food = mc.food
|
||||
comps.append(MealComponentRead(
|
||||
food_id=mc.food_id,
|
||||
quantity=mc.quantity,
|
||||
food={
|
||||
"id": comp_food.id,
|
||||
"name": comp_food.name,
|
||||
"brand": comp_food.brand,
|
||||
"unit_type": comp_food.unit_type,
|
||||
"calories_per_unit": comp_food.calories_per_unit,
|
||||
"serving_size_g": comp_food.serving_size_g,
|
||||
"serving_name": comp_food.serving_name,
|
||||
"is_meal": comp_food.is_meal,
|
||||
"deleted_at": comp_food.deleted_at,
|
||||
},
|
||||
))
|
||||
food_data["components"] = comps
|
||||
else:
|
||||
food_data["components"] = None
|
||||
|
||||
data["food"] = food_data
|
||||
# Compute nutrition for this entry (handles meals recursively)
|
||||
data["computed_nutrition"] = nutrition.entry_nutrition(entry.food, entry.quantity)
|
||||
return LogEntryRead.model_validate(data)
|
||||
|
||||
|
||||
def _build_meal_read(meal: Food) -> MealRead:
|
||||
"""Convert a meal Food ORM to MealRead with components & computed nutrition."""
|
||||
# Base food fields
|
||||
meal_data = {
|
||||
"id": meal.id,
|
||||
"name": meal.name,
|
||||
"brand": meal.brand,
|
||||
"barcode": meal.barcode,
|
||||
"source": meal.source,
|
||||
"is_meal": meal.is_meal,
|
||||
"unit_type": meal.unit_type,
|
||||
"calories_per_unit": meal.calories_per_unit,
|
||||
"protein_per_unit": meal.protein_per_unit,
|
||||
"carbs_per_unit": meal.carbs_per_unit,
|
||||
"fat_per_unit": meal.fat_per_unit,
|
||||
"fiber_per_unit": meal.fiber_per_unit,
|
||||
"saturated_fat_per_unit": meal.saturated_fat_per_unit,
|
||||
"sugars_per_unit": meal.sugars_per_unit,
|
||||
"sodium_per_unit": meal.sodium_per_unit,
|
||||
"serving_size_g": meal.serving_size_g,
|
||||
"serving_name": meal.serving_name,
|
||||
"deleted_at": meal.deleted_at,
|
||||
"created_at": meal.created_at,
|
||||
"updated_at": meal.updated_at,
|
||||
}
|
||||
|
||||
# Build component list
|
||||
comps = []
|
||||
if hasattr(meal, "components"):
|
||||
for mc in meal.components:
|
||||
comp_food = mc.food
|
||||
comps.append(MealComponentRead(
|
||||
food_id=mc.food_id,
|
||||
quantity=mc.quantity,
|
||||
food={
|
||||
"id": comp_food.id,
|
||||
"name": comp_food.name,
|
||||
"brand": comp_food.brand,
|
||||
"unit_type": comp_food.unit_type,
|
||||
"calories_per_unit": comp_food.calories_per_unit,
|
||||
"serving_size_g": comp_food.serving_size_g,
|
||||
"serving_name": comp_food.serving_name,
|
||||
"is_meal": comp_food.is_meal,
|
||||
"deleted_at": comp_food.deleted_at,
|
||||
},
|
||||
))
|
||||
meal_data["components"] = comps
|
||||
meal_data["computed_nutrition_per_meal"] = nutrition.entry_nutrition(meal, 1.0)
|
||||
|
||||
return MealRead.model_validate(meal_data)
|
||||
|
||||
|
||||
# ── Meal resolution (for foods.py get_food) ──────────────────────────────────
|
||||
|
||||
|
||||
def load_meal_with_components(db: Session, meal_id: int) -> Food | None:
|
||||
"""Eager-load a meal food with its components and their foods.
|
||||
|
||||
Returns None if the food doesn't exist or is not a meal.
|
||||
"""
|
||||
result = db.scalar(
|
||||
select(Food)
|
||||
.where(Food.id == meal_id, Food.is_meal.is_(True))
|
||||
.options(
|
||||
selectinload(Food.components).joinedload(MealComponent.food)
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Cycle detection ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MealCycleError(Exception):
|
||||
"""Raised when a component update would create a cycle in the meal graph."""
|
||||
def __init__(self, meal_id: int, food_id: int):
|
||||
super().__init__(
|
||||
f"Adding food {food_id} as a component of meal {meal_id} "
|
||||
f"would create a cycle"
|
||||
)
|
||||
self.meal_id = meal_id
|
||||
self.food_id = food_id
|
||||
|
||||
|
||||
def _check_cycle(db: Session, meal_id: int, proposed_food_ids: list[int]) -> None:
|
||||
"""Raise MealCycleError if adding proposed_food_ids as components of
|
||||
meal_id would create a cycle (directly or transitively).
|
||||
|
||||
Direct self-reference (food_id == meal_id) is also considered a cycle.
|
||||
"""
|
||||
# Build adjacency from all existing meal_components
|
||||
stmt = select(MealComponent.meal_id, MealComponent.food_id)
|
||||
rows = db.execute(stmt).all()
|
||||
|
||||
adj: dict[int, set[int]] = {}
|
||||
for row in rows:
|
||||
adj.setdefault(row.meal_id, set()).add(row.food_id)
|
||||
|
||||
# Add proposed edges
|
||||
for fid in proposed_food_ids:
|
||||
adj.setdefault(meal_id, set()).add(fid)
|
||||
|
||||
# DFS from each proposed food_id; if meal_id is reachable, it's a cycle
|
||||
for fid in proposed_food_ids:
|
||||
if _dfs_reachable(adj, fid, meal_id, set()):
|
||||
raise MealCycleError(meal_id, fid)
|
||||
|
||||
|
||||
def _dfs_reachable(
|
||||
adj: dict[int, set[int]], current: int, target: int, visited: set[int],
|
||||
) -> bool:
|
||||
"""Return True if target is reachable from current in the adjacency graph."""
|
||||
if current == target:
|
||||
return True
|
||||
if current in visited:
|
||||
return False
|
||||
visited.add(current)
|
||||
for neighbor in adj.get(current, set()):
|
||||
if _dfs_reachable(adj, neighbor, target, visited):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── create_meal_from_log (§4.3) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_meal_from_log(
|
||||
db: Session, lookup_date: date, entry_ids: list[int], name: str,
|
||||
) -> tuple[FoodRead, LogEntryRead]:
|
||||
"""Create a meal from selected daily_log entries. Runs as one transaction.
|
||||
|
||||
Steps:
|
||||
1. Load the source entries; validate they all exist and belong to ``date``.
|
||||
2. Create a new Food (is_meal=True, source="meal").
|
||||
3. Create MealComponent rows (quantity = original entry.quantity).
|
||||
4. Delete the source entries.
|
||||
5. Insert ONE replacement entry with quantity=1.0.
|
||||
6. Commit once; roll back entirely on any failure.
|
||||
|
||||
Returns (FoodRead of the new meal, LogEntryRead of the replacement entry).
|
||||
"""
|
||||
# 1. Load & validate source entries
|
||||
source_entries: list[DailyLogEntry] = []
|
||||
for eid in entry_ids:
|
||||
entry = db.get(DailyLogEntry, eid)
|
||||
if entry is None:
|
||||
raise EntryNotFoundError(eid)
|
||||
if entry.date != lookup_date:
|
||||
raise EntryDateMismatchError(eid, entry.date, lookup_date)
|
||||
source_entries.append(entry)
|
||||
|
||||
if not source_entries:
|
||||
raise ValueError("No valid source entries provided")
|
||||
|
||||
# 2. Create the meal food
|
||||
now = _now()
|
||||
meal = Food(
|
||||
name=name,
|
||||
brand=None,
|
||||
barcode=None,
|
||||
source="meal",
|
||||
is_meal=True,
|
||||
unit_type="weight", # placeholder — meals use quantity as scaling factor
|
||||
calories_per_unit=None,
|
||||
protein_per_unit=None,
|
||||
carbs_per_unit=None,
|
||||
fat_per_unit=None,
|
||||
fiber_per_unit=None,
|
||||
saturated_fat_per_unit=None,
|
||||
sugars_per_unit=None,
|
||||
sodium_per_unit=None,
|
||||
serving_size_g=None,
|
||||
serving_name=None,
|
||||
off_data=None,
|
||||
deleted_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(meal)
|
||||
db.flush() # get meal.id
|
||||
|
||||
# 3. Create MealComponent rows
|
||||
for entry in source_entries:
|
||||
mc = MealComponent(
|
||||
meal_id=meal.id,
|
||||
food_id=entry.food_id,
|
||||
quantity=entry.quantity,
|
||||
)
|
||||
db.add(mc)
|
||||
|
||||
# 4. Delete source entries
|
||||
max_sort = 0
|
||||
meal_slots: set[str | None] = set()
|
||||
for entry in source_entries:
|
||||
if entry.sort_order > max_sort:
|
||||
max_sort = entry.sort_order
|
||||
meal_slots.add(entry.meal_slot)
|
||||
db.delete(entry)
|
||||
|
||||
# 5. Insert replacement entry
|
||||
# meal_slot: use the first source entry's slot if all agree, else None
|
||||
replacement_slot = source_entries[0].meal_slot if len(meal_slots) == 1 else None
|
||||
replacement = DailyLogEntry(
|
||||
date=lookup_date,
|
||||
food_id=meal.id,
|
||||
quantity=1.0,
|
||||
meal_slot=replacement_slot,
|
||||
sort_order=max_sort,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(replacement)
|
||||
db.flush() # get replacement.id
|
||||
|
||||
# Commit
|
||||
db.commit()
|
||||
|
||||
# Re-query for response with eager-loaded relationships
|
||||
meal_loaded = load_meal_with_components(db, meal.id)
|
||||
entry_loaded = _load_entry_with_food_components(db, replacement.id)
|
||||
|
||||
meal_read = _build_meal_read(meal_loaded)
|
||||
entry_read = _build_log_entry_read(entry_loaded)
|
||||
return meal_read, entry_read
|
||||
|
||||
|
||||
# ── unpack_meal (§4.4) ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def unpack_meal(
|
||||
db: Session, meal_id: int, lookup_date: date, entry_id: int | None = None,
|
||||
) -> list[LogEntryRead]:
|
||||
"""Replace a logged meal entry with its component entries.
|
||||
|
||||
Runs as one transaction:
|
||||
1. Find the target log entry (by entry_id or auto-detect).
|
||||
2. Flatten the meal recursively to LEAF foods.
|
||||
3. Insert new entries for each leaf.
|
||||
4. Delete the original meal entry.
|
||||
5. Commit once; roll back entirely on any failure.
|
||||
|
||||
If ``entry_id`` is given, it must point at a log entry for ``meal_id``
|
||||
on ``lookup_date``. If ``entry_id`` is None, the entry is auto-detected:
|
||||
exactly one matching entry must exist, or an error is raised.
|
||||
"""
|
||||
# 1. Find the target log entry
|
||||
if entry_id is not None:
|
||||
target = db.scalar(
|
||||
select(DailyLogEntry)
|
||||
.where(
|
||||
DailyLogEntry.id == entry_id,
|
||||
DailyLogEntry.food_id == meal_id,
|
||||
DailyLogEntry.date == lookup_date,
|
||||
)
|
||||
.options(
|
||||
joinedload(DailyLogEntry.food)
|
||||
.selectinload(Food.components)
|
||||
.joinedload(MealComponent.food)
|
||||
)
|
||||
)
|
||||
if target is None:
|
||||
raise EntryNotFoundError(entry_id)
|
||||
else:
|
||||
candidates = db.scalars(
|
||||
select(DailyLogEntry)
|
||||
.where(
|
||||
DailyLogEntry.food_id == meal_id,
|
||||
DailyLogEntry.date == lookup_date,
|
||||
)
|
||||
.options(
|
||||
joinedload(DailyLogEntry.food)
|
||||
.selectinload(Food.components)
|
||||
.joinedload(MealComponent.food)
|
||||
)
|
||||
).unique().all()
|
||||
if len(candidates) == 0:
|
||||
raise ValueError(
|
||||
f"No log entry found for meal {meal_id} on {lookup_date}"
|
||||
)
|
||||
if len(candidates) > 1:
|
||||
raise AmbiguousMealEntryError(meal_id, lookup_date, len(candidates))
|
||||
target = candidates[0]
|
||||
|
||||
meal_food = target.food
|
||||
if not meal_food.is_meal:
|
||||
raise ValueError(f"Food {meal_id} is not a meal")
|
||||
|
||||
# 2. Flatten recursively to leaf foods
|
||||
leaf_entries = _flatten_meal(meal_food, target.quantity)
|
||||
|
||||
# 3. Insert new entries for each leaf
|
||||
sort_base = target.sort_order
|
||||
new_entries: list[DailyLogEntry] = []
|
||||
for i, (leaf_food_id, eff_qty) in enumerate(leaf_entries):
|
||||
entry = DailyLogEntry(
|
||||
date=lookup_date,
|
||||
food_id=leaf_food_id,
|
||||
quantity=eff_qty,
|
||||
meal_slot=target.meal_slot,
|
||||
sort_order=sort_base + i,
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(entry)
|
||||
new_entries.append(entry)
|
||||
|
||||
# 4. Delete the original meal entry
|
||||
db.delete(target)
|
||||
|
||||
# Commit
|
||||
db.commit()
|
||||
|
||||
# Re-query for responses with eager-loaded food
|
||||
result: list[LogEntryRead] = []
|
||||
for entry in new_entries:
|
||||
loaded = _load_entry_with_food(db, entry.id)
|
||||
result.append(_build_log_entry_read(loaded))
|
||||
return result
|
||||
|
||||
|
||||
def _flatten_meal(
|
||||
food: Food, scaling: float, visited: set[int] | None = None,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""Recursively flatten a meal to its leaf foods.
|
||||
|
||||
Returns a list of (food_id, effective_quantity) for each leaf food.
|
||||
Nested meals are expanded; their scaling factors are multiplied down.
|
||||
|
||||
``visited`` guards against cycles (safety net; cycles should be prevented
|
||||
by the write path). A cycled branch returns an empty list.
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if food.id in visited:
|
||||
return []
|
||||
|
||||
visited.add(food.id)
|
||||
|
||||
if not food.is_meal:
|
||||
return [(food.id, scaling)]
|
||||
|
||||
leaves: list[tuple[int, float]] = []
|
||||
for component in food.components:
|
||||
# component.quantity is the amount in ONE full meal; multiply by
|
||||
# the scaling factor for this log entry.
|
||||
leaves.extend(
|
||||
_flatten_meal(component.food, component.quantity * scaling, visited)
|
||||
)
|
||||
return leaves
|
||||
|
||||
|
||||
# ── update_meal_components (§3.2 PUT, cycle-checked) ─────────────────────────
|
||||
|
||||
|
||||
def update_meal_components(
|
||||
db: Session, meal_id: int, components: list[dict],
|
||||
) -> MealRead:
|
||||
"""Replace the component list for a meal food.
|
||||
|
||||
``components`` is a list of dicts with ``food_id`` and ``quantity`` keys.
|
||||
Validates that each food_id exists, quantity > 0, and the new component
|
||||
list doesn't create a cycle (direct or transitive self-reference).
|
||||
|
||||
Runs in one transaction: deletes existing components, inserts new ones,
|
||||
commits. Returns the updated MealRead.
|
||||
"""
|
||||
# Validate meal exists and is a meal
|
||||
meal = db.get(Food, meal_id)
|
||||
if meal is None or not meal.is_meal:
|
||||
raise MealNotFoundError(meal_id)
|
||||
|
||||
# Collect proposed food_ids
|
||||
proposed_ids = [c["food_id"] for c in components]
|
||||
|
||||
# Validate each food_id exists
|
||||
for fid in proposed_ids:
|
||||
if db.get(Food, fid) is None:
|
||||
raise ComponentFoodNotFoundError(fid)
|
||||
|
||||
# Cycle check: would adding these components create a cycle?
|
||||
_check_cycle(db, meal_id, proposed_ids)
|
||||
|
||||
# Replace: delete existing, insert new
|
||||
db.execute(
|
||||
MealComponent.__table__.delete().where(MealComponent.meal_id == meal_id)
|
||||
)
|
||||
for c in components:
|
||||
mc = MealComponent(
|
||||
meal_id=meal_id,
|
||||
food_id=c["food_id"],
|
||||
quantity=c["quantity"],
|
||||
)
|
||||
db.add(mc)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Re-query with components loaded
|
||||
meal = load_meal_with_components(db, meal_id)
|
||||
return _build_meal_read(meal)
|
||||
|
||||
|
||||
# ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EntryNotFoundError(Exception):
|
||||
def __init__(self, entry_id: int):
|
||||
super().__init__(f"Log entry {entry_id} not found")
|
||||
self.entry_id = entry_id
|
||||
|
||||
|
||||
class EntryDateMismatchError(Exception):
|
||||
def __init__(self, entry_id: int, entry_date: date, expected_date: date):
|
||||
super().__init__(
|
||||
f"Log entry {entry_id} has date {entry_date}, "
|
||||
f"not the expected {expected_date}"
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.entry_date = entry_date
|
||||
self.expected_date = expected_date
|
||||
|
||||
|
||||
class AmbiguousMealEntryError(Exception):
|
||||
def __init__(self, meal_id: int, lookup_date: date, count: int):
|
||||
super().__init__(
|
||||
f"Multiple ({count}) log entries for meal {meal_id} on {lookup_date}. "
|
||||
f"Specify entry_id to disambiguate."
|
||||
)
|
||||
self.meal_id = meal_id
|
||||
self.lookup_date = lookup_date
|
||||
self.count = count
|
||||
|
||||
|
||||
class MealNotFoundError(Exception):
|
||||
def __init__(self, meal_id: int):
|
||||
super().__init__(f"Meal {meal_id} not found or is not a meal")
|
||||
self.meal_id = meal_id
|
||||
|
||||
|
||||
class ComponentFoodNotFoundError(Exception):
|
||||
def __init__(self, food_id: int):
|
||||
super().__init__(f"Component food {food_id} not found")
|
||||
self.food_id = food_id
|
||||
|
||||
@@ -5,6 +5,10 @@ Routers never compute nutrition; the frontend never re-derives it.
|
||||
- unit_type "weight": quantity is grams; nutrition = (quantity / 100) × per_unit
|
||||
- unit_type "count": quantity is item count; nutrition = quantity × per_unit
|
||||
- meal: quantity is a scaling factor (1.0 = one full meal)
|
||||
|
||||
Meal nutrition is derived by recursive component summation (§2.2), with cycle
|
||||
detection. A cycle returns zeros for that branch (safety net — writes prevent
|
||||
cycles, but reads must never infinite-loop).
|
||||
"""
|
||||
|
||||
from models import Food
|
||||
@@ -42,31 +46,75 @@ def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -
|
||||
raise ValueError(f"unknown unit_type: {unit_type!r}")
|
||||
|
||||
|
||||
def entry_calories(food: Food, quantity: float) -> float:
|
||||
"""Calories for a single (non-meal) food at a logged quantity.
|
||||
def entry_calories(food: Food, quantity: float, visited: set[int] | None = None) -> float:
|
||||
"""Calories for a food at a logged quantity.
|
||||
|
||||
TODO: handle is_meal foods by summing scaled component nutrition
|
||||
(recursively, with cycle detection — spec §2.2).
|
||||
For regular foods: scales per_unit by quantity using unit_type.
|
||||
For meals: recursively sums scaled component nutrition. Cycle detection
|
||||
prevents infinite loops — a cycled branch returns 0 (safety net; writes
|
||||
should prevent cycles from being created).
|
||||
"""
|
||||
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if food.id in visited:
|
||||
return 0.0
|
||||
|
||||
if not food.is_meal:
|
||||
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
|
||||
|
||||
# Meal: recurse into components
|
||||
visited.add(food.id)
|
||||
total = 0.0
|
||||
for component in food.components:
|
||||
# component.quantity is the amount in ONE full meal; multiply by the
|
||||
# entry's scaling factor to get the effective quantity for this log entry.
|
||||
total += entry_calories(component.food, component.quantity * quantity, visited)
|
||||
return total
|
||||
|
||||
|
||||
def entry_nutrition(food: Food, quantity: float) -> dict[str, float]:
|
||||
def entry_nutrition(
|
||||
food: Food, quantity: float, visited: set[int] | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Return all nutrition fields scaled to a logged quantity.
|
||||
|
||||
Each field is resolved via scale_to_quantity using the food's unit_type.
|
||||
NULL per-unit values contribute 0.0, not an error.
|
||||
For regular foods: each field is resolved via scale_to_quantity using
|
||||
the food's unit_type. NULL per-unit values contribute 0.0.
|
||||
|
||||
Meal foods (is_meal=True) have null per_unit fields per the CHECK
|
||||
constraint, so they naturally contribute 0 for all fields.
|
||||
TODO: TICKET-007 — real meal nutrition will sum scaled component
|
||||
foods recursively. Until then, meal entries contribute 0.
|
||||
For meals: recursively sums scaled component nutrition (§2.2). Cycle
|
||||
detection prevents infinite loops — a cycled branch returns zeros for
|
||||
all fields (safety net; writes should prevent cycles from being created).
|
||||
|
||||
The ``visited`` set tracks food IDs on the current recursion path.
|
||||
Callers should NOT pre-populate it — it defaults to an empty set and
|
||||
is only used internally for recursion.
|
||||
"""
|
||||
return {
|
||||
field: scale_to_quantity(
|
||||
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
|
||||
quantity,
|
||||
food.unit_type,
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if food.id in visited:
|
||||
# Cycle detected — safety net; return zeros for this branch.
|
||||
return {field: 0.0 for field in NUTRITION_FIELDS}
|
||||
|
||||
if not food.is_meal:
|
||||
return {
|
||||
field: scale_to_quantity(
|
||||
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
|
||||
quantity,
|
||||
food.unit_type,
|
||||
)
|
||||
for field in NUTRITION_FIELDS
|
||||
}
|
||||
|
||||
# Meal: recurse into components, multiplying the scaling factor down.
|
||||
visited.add(food.id)
|
||||
totals = {field: 0.0 for field in NUTRITION_FIELDS}
|
||||
for component in food.components:
|
||||
# component.quantity is the amount in ONE full meal; multiply by the
|
||||
# entry's scaling factor to get the effective quantity for this log entry.
|
||||
component_nut = entry_nutrition(
|
||||
component.food, component.quantity * quantity, visited,
|
||||
)
|
||||
for field in NUTRITION_FIELDS
|
||||
}
|
||||
for field in NUTRITION_FIELDS:
|
||||
totals[field] += component_nut[field]
|
||||
return totals
|
||||
Reference in New Issue
Block a user