TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection
This commit is contained in:
+2
-1
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from database import run_migrations
|
||||
from routers import foods, log, off, targets
|
||||
from routers import foods, log, meals, off, targets
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -30,6 +30,7 @@ app.include_router(foods.router)
|
||||
app.include_router(log.router)
|
||||
app.include_router(targets.router)
|
||||
app.include_router(off.router)
|
||||
app.include_router(meals.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from schemas import FoodCreate, FoodRead, FoodUpdate
|
||||
from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead
|
||||
from services.foods import (
|
||||
BarcodeConflictError,
|
||||
create_food,
|
||||
@@ -43,9 +43,11 @@ def _list_foods(
|
||||
include_deleted=include_deleted)
|
||||
|
||||
|
||||
@router.get("/{food_id}", response_model=FoodRead)
|
||||
@router.get("/{food_id}")
|
||||
def _get_food(food_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a single food, including soft-deleted ones (for historical logs)."""
|
||||
"""Get a single food, including soft-deleted ones (for historical logs).
|
||||
Returns MealRead (with components + computed nutrition) for meals,
|
||||
plain FoodRead for non-meals."""
|
||||
food = get_food(db, food_id)
|
||||
if food is None:
|
||||
raise HTTPException(status_code=404, detail="Food not found")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Meals router (spec §3.2). Thin handlers — business logic in services/meals.py."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from schemas import (
|
||||
LogEntryRead,
|
||||
MealComponentsUpdateRequest,
|
||||
MealFromLogRequest,
|
||||
MealFromLogResponse,
|
||||
MealRead,
|
||||
MealUnpackRequest,
|
||||
MealUnpackResponse,
|
||||
)
|
||||
from services import meals as svc
|
||||
|
||||
router = APIRouter(prefix="/api/meals", tags=["meals"])
|
||||
|
||||
|
||||
@router.post("/from-log", response_model=MealFromLogResponse, status_code=201)
|
||||
def create_meal_from_log(data: MealFromLogRequest, db: Session = Depends(get_db)):
|
||||
"""Create a meal food from selected daily log entries (§4.3).
|
||||
|
||||
Runs as one transaction: creates the meal + components, deletes source
|
||||
entries, inserts one replacement entry. Rolls back entirely on failure.
|
||||
"""
|
||||
try:
|
||||
meal, entry = svc.create_meal_from_log(
|
||||
db, data.date, data.entry_ids, data.name,
|
||||
)
|
||||
except svc.EntryNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except svc.EntryDateMismatchError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return MealFromLogResponse(meal=meal, entry=entry)
|
||||
|
||||
|
||||
@router.post("/{meal_id}/unpack", response_model=MealUnpackResponse)
|
||||
def unpack_meal(
|
||||
meal_id: int,
|
||||
data: MealUnpackRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replace a logged meal entry with its leaf component entries (§4.4).
|
||||
|
||||
Components are resolved recursively — nested meals are flattened to
|
||||
leaf foods with their scaling factors multiplied down.
|
||||
|
||||
Runs as one transaction. If multiple entries match the meal on this
|
||||
date, specify ``entry_id`` to disambiguate.
|
||||
"""
|
||||
try:
|
||||
entries = svc.unpack_meal(db, meal_id, data.date, data.entry_id)
|
||||
except svc.EntryNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except svc.AmbiguousMealEntryError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return MealUnpackResponse(entries=entries)
|
||||
|
||||
|
||||
@router.put("/{meal_id}/components", response_model=MealRead)
|
||||
def update_meal_components(
|
||||
meal_id: int,
|
||||
data: MealComponentsUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replace a meal's component list (§3.2 PUT). Cycle-checked.
|
||||
|
||||
Components are validated for cycles (direct or transitive self-reference).
|
||||
A 422 is returned when the update would create a cycle.
|
||||
|
||||
This is a full replacement — all existing components are removed and
|
||||
replaced with the provided list.
|
||||
"""
|
||||
comps = [{"food_id": c.food_id, "quantity": c.quantity} for c in data.components]
|
||||
|
||||
try:
|
||||
return svc.update_meal_components(db, meal_id, comps)
|
||||
except svc.MealNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except svc.ComponentFoodNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except svc.MealCycleError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
+88
-19
@@ -4,6 +4,8 @@ Defined separately from ORM models; services convert via from_attributes.
|
||||
Validation happens here at the boundary (spec §8.1 rule 11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
@@ -93,9 +95,55 @@ class FoodRead(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ── Meal components & MealRead (§3.2, TICKET-007) ────────────────────────────
|
||||
|
||||
|
||||
class MealComponentRead(BaseModel):
|
||||
"""A single component within a meal, with the component food embedded
|
||||
so the frontend can render names/nutrition without N+1 lookups."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
food_id: int
|
||||
quantity: float
|
||||
food: "LogFoodRead"
|
||||
|
||||
|
||||
class MealRead(FoodRead):
|
||||
"""A meal food with its components and computed per-1.0-meal nutrition.
|
||||
|
||||
Returned by GET /api/foods/{id} when the food is a meal. For non-meal
|
||||
foods the plain FoodRead is returned instead.
|
||||
"""
|
||||
components: list[MealComponentRead] = []
|
||||
computed_nutrition_per_meal: dict[str, float] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Log ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LogFoodRead(BaseModel):
|
||||
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
|
||||
Includes name, brand, unit_type, calories_per_unit, and serving info so the
|
||||
frontend can render log entries without N+1 lookups. Soft-deleted foods
|
||||
render here (§2.1).
|
||||
|
||||
When is_meal is True, components is populated with the meal's components
|
||||
(with their own food embedded) so the frontend can render collapsible rows.
|
||||
"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
brand: str | None
|
||||
unit_type: UnitType
|
||||
calories_per_unit: float | None
|
||||
serving_size_g: float | None
|
||||
serving_name: str | None
|
||||
is_meal: bool
|
||||
deleted_at: datetime | None
|
||||
components: list[MealComponentRead] | None = None
|
||||
|
||||
|
||||
class LogEntryCreate(BaseModel):
|
||||
food_id: int
|
||||
quantity: float = Field(gt=0)
|
||||
@@ -111,24 +159,6 @@ class LogEntryUpdate(BaseModel):
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class LogFoodRead(BaseModel):
|
||||
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
|
||||
Includes name, brand, unit_type, calories_per_unit, and serving info so the
|
||||
frontend can render log entries without N+1 lookups. Soft-deleted foods
|
||||
render here (§2.1)."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
brand: str | None
|
||||
unit_type: UnitType
|
||||
calories_per_unit: float | None
|
||||
serving_size_g: float | None
|
||||
serving_name: str | None
|
||||
is_meal: bool
|
||||
deleted_at: datetime | None
|
||||
|
||||
|
||||
class LogEntryRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -139,6 +169,45 @@ class LogEntryRead(BaseModel):
|
||||
meal_slot: MealSlot | None
|
||||
sort_order: int
|
||||
food: LogFoodRead
|
||||
computed_nutrition: dict[str, float] | None = None
|
||||
|
||||
|
||||
# ── Meal endpoints (TICKET-007) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class MealFromLogRequest(BaseModel):
|
||||
"""Body for POST /api/meals/from-log."""
|
||||
name: str
|
||||
date: date
|
||||
entry_ids: list[int] = Field(min_length=1)
|
||||
|
||||
|
||||
class MealFromLogResponse(BaseModel):
|
||||
"""Response for POST /api/meals/from-log."""
|
||||
meal: MealRead
|
||||
entry: LogEntryRead
|
||||
|
||||
|
||||
class MealUnpackRequest(BaseModel):
|
||||
"""Body for POST /api/meals/{meal_id}/unpack."""
|
||||
date: date
|
||||
entry_id: int | None = None
|
||||
|
||||
|
||||
class MealUnpackResponse(BaseModel):
|
||||
"""Response for POST /api/meals/{meal_id}/unpack."""
|
||||
entries: list[LogEntryRead]
|
||||
|
||||
|
||||
class MealComponentInput(BaseModel):
|
||||
"""A single component in a PUT /api/meals/{meal_id}/components request."""
|
||||
food_id: int
|
||||
quantity: float = Field(gt=0)
|
||||
|
||||
|
||||
class MealComponentsUpdateRequest(BaseModel):
|
||||
"""Body for PUT /api/meals/{meal_id}/components — full replacement."""
|
||||
components: list[MealComponentInput]
|
||||
|
||||
|
||||
# ── Targets ──────────────────────────────────────────────────────────────────
|
||||
@@ -218,4 +287,4 @@ class DaySummaryResponse(BaseModel):
|
||||
|
||||
date: date
|
||||
totals: DaySummaryNutrition
|
||||
target: TargetRead | None
|
||||
target: TargetRead | None
|
||||
@@ -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
|
||||
@@ -0,0 +1,773 @@
|
||||
"""Meal tests — TICKET-007 (spec §2.2, §3.2, §4.3, §4.4, §8.1 rule 6, §8.4).
|
||||
|
||||
Covers:
|
||||
- Nutrition recursion: meal of components, scaling factors, nested meals,
|
||||
null nutrition fields contribute 0
|
||||
- Cycle detection on nutrition reads: manually-constructed cycle returns zeros
|
||||
- POST /api/meals/from-log happy path + rollback on failure
|
||||
- POST /api/meals/{meal_id}/unpack happy path (incl. 1.5× scaling, nested
|
||||
meal flattening) + rollback on failure
|
||||
- PUT /api/meals/{meal_id}/components cycle rejection (transitive + direct
|
||||
self-reference) + valid non-cyclic replacement
|
||||
- Summary includes real meal nutrition (no longer 0 for meals)
|
||||
- GET /api/log includes nested components for meal entries
|
||||
- GET /api/foods/{id} returns MealRead for meals
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_food(client, **overrides) -> dict:
|
||||
"""Create a food via POST and return the response JSON."""
|
||||
payload = {
|
||||
"name": "Test Food",
|
||||
"calories_per_unit": 250.0,
|
||||
"source": "manual",
|
||||
"unit_type": "weight",
|
||||
}
|
||||
payload.update(overrides)
|
||||
resp = client.post("/api/foods", json=payload)
|
||||
assert resp.status_code == 201, f"food create failed: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _create_meal(client, name, is_meal=True, source="meal") -> dict:
|
||||
"""Create a meal food (is_meal=True, calories_per_unit=None)."""
|
||||
return _create_food(
|
||||
client, name=name, is_meal=is_meal, calories_per_unit=None, source=source,
|
||||
)
|
||||
|
||||
|
||||
def _log_entry(client, food_id, quantity, date="2025-06-15", **overrides) -> dict:
|
||||
"""Create a log entry and return the parsed JSON (asserts 201)."""
|
||||
payload = {"food_id": food_id, "quantity": quantity, "date": date}
|
||||
payload.update(overrides)
|
||||
resp = client.post("/api/log", json=payload)
|
||||
assert resp.status_code == 201, f"log create failed: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get_summary(client, date="2025-06-15") -> dict:
|
||||
"""Call the summary endpoint and return parsed JSON (asserts 200)."""
|
||||
resp = client.get("/api/log/summary", params={"date": date})
|
||||
assert resp.status_code == 200, f"summary failed: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _get_log(client, date="2025-06-15") -> list[dict]:
|
||||
"""Call GET /api/log?date= and return parsed JSON (asserts 200)."""
|
||||
resp = client.get("/api/log", params={"date": date})
|
||||
assert resp.status_code == 200, f"log GET failed: {resp.text}"
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _from_log(client, name, date, entry_ids) -> dict:
|
||||
"""Call POST /api/meals/from-log and return parsed JSON (asserts 201)."""
|
||||
resp = client.post(
|
||||
"/api/meals/from-log",
|
||||
json={"name": name, "date": date, "entry_ids": entry_ids},
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _unpack_meal(client, meal_id, date, entry_id=None) -> dict:
|
||||
"""Call POST /api/meals/{meal_id}/unpack and return parsed JSON (asserts 200)."""
|
||||
body = {"date": date}
|
||||
if entry_id is not None:
|
||||
body["entry_id"] = entry_id
|
||||
resp = client.post(f"/api/meals/{meal_id}/unpack", json=body)
|
||||
return resp
|
||||
|
||||
|
||||
def _update_components(client, meal_id, components) -> dict:
|
||||
"""Call PUT /api/meals/{meal_id}/components and return response."""
|
||||
resp = client.put(
|
||||
f"/api/meals/{meal_id}/components",
|
||||
json={"components": components},
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
# ── Nutrition recursion (§8.1 rule 1) ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_meal_nutrition_two_components_weight_type(client):
|
||||
"""A meal of 100g rice (130 kcal/100g) + 1 egg (70 kcal/count) →
|
||||
one meal = 130 + 70 = 200 kcal. Logged at 1.0× → 200 kcal."""
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
|
||||
|
||||
# Create a meal from scratch via the service (bypassing from-log to test pure nutrition)
|
||||
date = "2025-07-20"
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
_log_entry(client, egg["id"], quantity=1.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Rice + Egg", date, _get_entry_ids(client, date))
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
meal = data["meal"]
|
||||
entry = data["entry"]
|
||||
|
||||
# The replacement entry should be quantity=1.0
|
||||
assert entry["quantity"] == 1.0
|
||||
|
||||
# Summary should reflect real meal nutrition (200 kcal)
|
||||
summary = _get_summary(client, date)
|
||||
assert summary["totals"]["calories"] == 200.0
|
||||
|
||||
|
||||
def test_meal_nutrition_scaled_1_5x(client):
|
||||
"""A meal logged at 1.5× should contribute 1.5× the nutrition."""
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
|
||||
|
||||
date = "2025-07-21"
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
_log_entry(client, egg["id"], quantity=1.0, date=date)
|
||||
|
||||
# Create meal from log
|
||||
resp = _from_log(client, "Scaled Meal", date, _get_entry_ids(client, date))
|
||||
assert resp.status_code == 201, resp.text
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
|
||||
# Log the meal at 1.5×
|
||||
_log_entry(client, meal_id, quantity=1.5, date=date)
|
||||
|
||||
summary = _get_summary(client, date)
|
||||
# One meal at 1.0× (200 kcal) + one at 1.5× (300 kcal) = 500 total
|
||||
assert summary["totals"]["calories"] == 500.0
|
||||
|
||||
|
||||
def test_meal_nutrition_nested_meals(client):
|
||||
"""A meal containing another meal → recursion depth 2 sums correctly."""
|
||||
# Component foods
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
date = "2025-07-22"
|
||||
|
||||
# First: create an inner meal (rice + chicken)
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
_log_entry(client, chicken["id"], quantity=200.0, date=date)
|
||||
inner_resp = _from_log(client, "Inner Meal", date, _get_entry_ids(client, date))
|
||||
assert inner_resp.status_code == 201
|
||||
inner_meal_id = inner_resp.json()["meal"]["id"]
|
||||
inner_entry_id = inner_resp.json()["entry"]["id"]
|
||||
# inner meal: 100g rice (130 kcal) + 200g chicken (330 kcal) = 460 kcal
|
||||
|
||||
# Second: create outer meal containing inner meal + another food
|
||||
# We need to update inner meal's components to prepare, or just use from-log
|
||||
# with the existing inner meal log entry
|
||||
apple = _create_food(client, name="Apple", calories_per_unit=52.0, unit_type="weight")
|
||||
_log_entry(client, apple["id"], quantity=150.0, date=date) # 78 kcal
|
||||
|
||||
# Now the date has: inner_meal_entry (1.0x → 460 kcal) + apple entry (78 kcal)
|
||||
all_eids = _get_entry_ids(client, date)
|
||||
outer_resp = _from_log(client, "Outer Meal", date, all_eids)
|
||||
assert outer_resp.status_code == 201
|
||||
outer_meal_id = outer_resp.json()["meal"]["id"]
|
||||
|
||||
# Check summary: the outer meal at 1.0× should be 460 + 78 = 538 kcal
|
||||
summary = _get_summary(client, date)
|
||||
assert summary["totals"]["calories"] == 538.0
|
||||
|
||||
# GET the outer meal: computed_nutrition_per_meal should be 538
|
||||
meal_resp = client.get(f"/api/foods/{outer_meal_id}")
|
||||
assert meal_resp.status_code == 200
|
||||
meal_data = meal_resp.json()
|
||||
assert "components" in meal_data
|
||||
assert "computed_nutrition_per_meal" in meal_data
|
||||
assert meal_data["computed_nutrition_per_meal"]["calories"] == 538.0
|
||||
|
||||
|
||||
def test_meal_nutrition_null_fields_contribute_zero(client):
|
||||
"""A component with null calories_per_unit contributes 0."""
|
||||
# Create a meal-type food (null per_unit) as a component
|
||||
placeholder = _create_meal(client, "Placeholder")
|
||||
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
|
||||
date = "2025-07-23"
|
||||
# Log and create meal from them
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
_log_entry(client, placeholder["id"], quantity=1.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Mixed Meal", date, _get_entry_ids(client, date))
|
||||
assert resp.status_code == 201
|
||||
|
||||
# Summary: only rice contributes (130 kcal), placeholder is 0
|
||||
summary = _get_summary(client, date)
|
||||
assert summary["totals"]["calories"] == 130.0
|
||||
|
||||
|
||||
# ── Cycle detection on nutrition reads (§8.1 rule 1 safety net) ─────────────
|
||||
|
||||
|
||||
def test_cycle_detection_on_nutrition_reads(client):
|
||||
"""A manually-constructed cycle (bypassing the write check) returns zeros,
|
||||
doesn't infinite-loop."""
|
||||
from database import SessionLocal
|
||||
from models import Food, MealComponent
|
||||
|
||||
date = "2025-07-24"
|
||||
|
||||
# Create two meal foods
|
||||
meal_a = _create_meal(client, "Meal A")
|
||||
meal_b = _create_meal(client, "Meal B")
|
||||
|
||||
# Manually insert cycle: A → B → A
|
||||
db = SessionLocal()
|
||||
try:
|
||||
mc1 = MealComponent(meal_id=meal_a["id"], food_id=meal_b["id"], quantity=1.0)
|
||||
mc2 = MealComponent(meal_id=meal_b["id"], food_id=meal_a["id"], quantity=1.0)
|
||||
db.add(mc1)
|
||||
db.add(mc2)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Log meal A
|
||||
_log_entry(client, meal_a["id"], quantity=1.0, date=date)
|
||||
|
||||
# Summary should NOT infinite-loop; cycle returns 0
|
||||
summary = _get_summary(client, date)
|
||||
assert summary["totals"]["calories"] == 0.0 # cycle → zeros
|
||||
|
||||
# Cleanup
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(MealComponent).filter(
|
||||
MealComponent.meal_id.in_([meal_a["id"], meal_b["id"]])
|
||||
).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── POST /api/meals/from-log happy path ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_from_log_happy_path(client):
|
||||
"""Create 2 foods, log them to today, from-log with both entry_ids → 201,
|
||||
meal food created, components with right quantities, original 2 entries
|
||||
deleted, 1 replacement entry (quantity=1.0)."""
|
||||
date = "2025-07-25"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
|
||||
|
||||
entry_ids = [e1["id"], e2["id"]]
|
||||
|
||||
resp = _from_log(client, "Lunch Meal", date, entry_ids)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
|
||||
# Response shape
|
||||
assert "meal" in data
|
||||
assert "entry" in data
|
||||
|
||||
meal = data["meal"]
|
||||
assert meal["is_meal"] is True
|
||||
assert meal["source"] == "meal"
|
||||
assert meal["name"] == "Lunch Meal"
|
||||
assert meal["calories_per_unit"] is None
|
||||
assert meal["protein_per_unit"] is None
|
||||
|
||||
entry = data["entry"]
|
||||
assert entry["quantity"] == 1.0
|
||||
assert entry["date"] == date
|
||||
assert entry["food_id"] == meal["id"]
|
||||
|
||||
# Original entries are gone
|
||||
log_entries = _get_log(client, date)
|
||||
assert len(log_entries) == 1
|
||||
assert log_entries[0]["id"] == entry["id"]
|
||||
|
||||
# GET the meal: should have components
|
||||
meal_resp = client.get(f"/api/foods/{meal['id']}")
|
||||
assert meal_resp.status_code == 200
|
||||
meal_data = meal_resp.json()
|
||||
assert len(meal_data["components"]) == 2
|
||||
comp_food_ids = {c["food_id"] for c in meal_data["components"]}
|
||||
assert comp_food_ids == {f1["id"], f2["id"]}
|
||||
|
||||
# Check component quantities match original log entries
|
||||
for c in meal_data["components"]:
|
||||
if c["food_id"] == f1["id"]:
|
||||
assert c["quantity"] == 200.0
|
||||
elif c["food_id"] == f2["id"]:
|
||||
assert c["quantity"] == 150.0
|
||||
|
||||
# Summary matches sum of originals
|
||||
summary = _get_summary(client, date)
|
||||
# 130 × 200/100 = 260, 165 × 150/100 = 247.5 → 507.5
|
||||
assert summary["totals"]["calories"] == 507.5
|
||||
|
||||
|
||||
def test_from_log_meal_components_nested_in_log(client):
|
||||
"""GET /api/log response includes meal components nested for rendering
|
||||
collapsible rows (§3.3)."""
|
||||
date = "2025-07-26"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Lunch Meal", date, [e1["id"], e2["id"]])
|
||||
assert resp.status_code == 201
|
||||
|
||||
# GET /api/log should show the meal with nested components
|
||||
entries = _get_log(client, date)
|
||||
assert len(entries) == 1
|
||||
food = entries[0]["food"]
|
||||
assert food["is_meal"] is True
|
||||
assert food["components"] is not None
|
||||
assert len(food["components"]) == 2
|
||||
|
||||
|
||||
# ── POST /api/meals/from-log rollback on failure ────────────────────────────
|
||||
|
||||
|
||||
def test_from_log_rollback_on_bad_entry(client):
|
||||
"""One entry_id doesn't exist → 404, nothing was written."""
|
||||
date = "2025-07-27"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
|
||||
# Try from-log with a non-existent entry_id
|
||||
resp = _from_log(client, "Bad Meal", date, [e1["id"], 99999])
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
# Original entries still exist
|
||||
log_entries = _get_log(client, date)
|
||||
assert len(log_entries) == 1
|
||||
assert log_entries[0]["id"] == e1["id"]
|
||||
|
||||
# No meal food was created (search for meals with source="meal")
|
||||
foods_resp = client.get("/api/foods", params={"limit": 200})
|
||||
meal_foods = [f for f in foods_resp.json() if f["source"] == "meal" and f["name"] == "Bad Meal"]
|
||||
assert len(meal_foods) == 0
|
||||
|
||||
|
||||
def test_from_log_rollback_on_wrong_date(client):
|
||||
"""Entry from a different date → 400, nothing was written."""
|
||||
date_a = "2025-07-28"
|
||||
date_b = "2025-07-29"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date_a)
|
||||
|
||||
# Try from-log requesting date_b but entry is on date_a
|
||||
resp = _from_log(client, "Wrong Date Meal", date_b, [e1["id"]])
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
# Entry still exists on date_a
|
||||
log_entries = _get_log(client, date_a)
|
||||
assert len(log_entries) == 1
|
||||
assert log_entries[0]["id"] == e1["id"]
|
||||
|
||||
|
||||
# ── POST /api/meals/{meal_id}/unpack happy path ─────────────────────────────
|
||||
|
||||
|
||||
def test_unpack_happy_path(client):
|
||||
"""Log a meal at 1.0×, unpack → meal entry deleted, component entries
|
||||
inserted with component.quantity × 1.0. Summary unchanged."""
|
||||
date = "2025-07-30"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
|
||||
|
||||
# Create meal
|
||||
resp = _from_log(client, "Lunch", date, [e1["id"], e2["id"]])
|
||||
assert resp.status_code == 201
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
meal_entry_id = resp.json()["entry"]["id"]
|
||||
|
||||
# Summary before unpack
|
||||
summary_before = _get_summary(client, date)
|
||||
assert summary_before["totals"]["calories"] == 507.5 # 260 + 247.5
|
||||
|
||||
# Unpack
|
||||
unpack_resp = _unpack_meal(client, meal_id, date)
|
||||
assert unpack_resp.status_code == 200, unpack_resp.text
|
||||
unpack_data = unpack_resp.json()
|
||||
entries = unpack_data["entries"]
|
||||
assert len(entries) == 2
|
||||
|
||||
# Meal entry is gone
|
||||
log_entries = _get_log(client, date)
|
||||
log_ids = {e["id"] for e in log_entries}
|
||||
assert meal_entry_id not in log_ids
|
||||
assert len(log_entries) == 2
|
||||
|
||||
# Summary unchanged
|
||||
summary_after = _get_summary(client, date)
|
||||
assert summary_after["totals"]["calories"] == 507.5
|
||||
|
||||
|
||||
def test_unpack_with_scaling_1_5x(client):
|
||||
"""Log a meal at 1.5×, unpack → component entries have quantity × 1.5."""
|
||||
date = "2025-07-31"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Lunch", date, [e1["id"], e2["id"]])
|
||||
assert resp.status_code == 201
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
meal_entry_id = resp.json()["entry"]["id"]
|
||||
|
||||
# Update meal entry to 1.5×
|
||||
client.put(f"/api/log/{meal_entry_id}", json={"quantity": 1.5})
|
||||
|
||||
summary_before = _get_summary(client, date)
|
||||
assert summary_before["totals"]["calories"] == 761.25 # 507.5 × 1.5
|
||||
|
||||
# Unpack
|
||||
unpack_resp = _unpack_meal(client, meal_id, date)
|
||||
assert unpack_resp.status_code == 200, unpack_resp.text
|
||||
unpack_data = unpack_resp.json()
|
||||
|
||||
# Each component should be scaled: 200*1.5=300, 150*1.5=225
|
||||
entries = unpack_data["entries"]
|
||||
qty_by_food = {}
|
||||
for e in entries:
|
||||
qty_by_food[e["food_id"]] = e["quantity"]
|
||||
|
||||
assert qty_by_food.get(f1["id"]) == 300.0
|
||||
assert qty_by_food.get(f2["id"]) == 225.0
|
||||
|
||||
# Summary unchanged
|
||||
summary_after = _get_summary(client, date)
|
||||
assert summary_after["totals"]["calories"] == 761.25
|
||||
|
||||
|
||||
def test_unpack_nested_meal_flattens_to_leaves(client):
|
||||
"""Unpacking a meal containing a nested meal → flattens to leaf foods
|
||||
with scaling factors multiplied down the chain.
|
||||
|
||||
1.5× outer containing a 2-component inner meal:
|
||||
Inner: 100g rice (130 kcal/100g = 130) + 1 egg (70 kcal = 70) = 200 kcal
|
||||
Outer: inner at 0.5 (half portion) + apple 100g (52 kcal/100g = 52) = 100 + 52 = 152
|
||||
Log at 1.5× → 228 kcal. Unpack → 4 leaf entries each ×1.5×nested-scaling.
|
||||
"""
|
||||
date = "2025-08-01"
|
||||
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
|
||||
apple = _create_food(client, name="Apple", calories_per_unit=52.0, unit_type="weight")
|
||||
|
||||
# Step 1: Log rice + egg, create inner meal
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
e_egg = _log_entry(client, egg["id"], quantity=1.0, date=date)
|
||||
inner_resp = _from_log(client, "Inner", date, _get_entry_ids(client, date))
|
||||
assert inner_resp.status_code == 201
|
||||
inner_meal_id = inner_resp.json()["meal"]["id"]
|
||||
inner_entry_id = inner_resp.json()["entry"]["id"]
|
||||
|
||||
# Step 2: Update inner entry to 0.5× (half portion)
|
||||
client.put(f"/api/log/{inner_entry_id}", json={"quantity": 0.5})
|
||||
|
||||
# Step 3: Also log apple (100g = 52 kcal)
|
||||
e_apple = _log_entry(client, apple["id"], quantity=100.0, date=date)
|
||||
|
||||
# Step 4: Create outer meal from inner meal entry (0.5×) + apple entry
|
||||
outer_resp = _from_log(client, "Outer", date, [inner_entry_id, e_apple["id"]])
|
||||
assert outer_resp.status_code == 201, outer_resp.text
|
||||
outer_meal_id = outer_resp.json()["meal"]["id"]
|
||||
outer_entry_id = outer_resp.json()["entry"]["id"]
|
||||
|
||||
# Step 5: Update outer entry to 1.5×
|
||||
client.put(f"/api/log/{outer_entry_id}", json={"quantity": 1.5})
|
||||
|
||||
# Summary before unpack:
|
||||
# Outer at 1.5×: inner component (0.5 portion of inner meal):
|
||||
# inner meal per 1.0 = 100g rice (130) + 1 egg (70) = 200 kcal
|
||||
# inner at 0.5 portion = 100 kcal
|
||||
# apple at 100g = 52 kcal
|
||||
# Outer per 1.0 = 100 + 52 = 152 kcal
|
||||
# Outer at 1.5× = 228 kcal
|
||||
summary_before = _get_summary(client, date)
|
||||
assert summary_before["totals"]["calories"] == 228.0
|
||||
|
||||
# Step 6: Unpack → should flatten to 4 leaf entries
|
||||
unpack_resp = _unpack_meal(client, outer_meal_id, date)
|
||||
assert unpack_resp.status_code == 200, unpack_resp.text
|
||||
entries = unpack_resp.json()["entries"]
|
||||
|
||||
# Expected leaf foods:
|
||||
# - rice: 100g * 0.5 (inner portion) * 1.5 (outer scaling) = 75g → 97.5 kcal
|
||||
# - egg: 1.0 * 0.5 * 1.5 = 0.75 → 52.5 kcal
|
||||
# - apple: 100g * 1.5 = 150g → 78 kcal
|
||||
# Total = 97.5 + 52.5 + 78 = 228
|
||||
assert len(entries) == 3 # rice, egg, apple (all leaf foods)
|
||||
|
||||
leaf_qtys = {}
|
||||
for e in entries:
|
||||
fid = e["food_id"]
|
||||
leaf_qtys[fid] = leaf_qtys.get(fid, 0.0) + e["quantity"]
|
||||
|
||||
# rice: 100 * 0.5 * 1.5 = 75
|
||||
assert leaf_qtys.get(rice["id"]) == 75.0
|
||||
# egg: 1 * 0.5 * 1.5 = 0.75
|
||||
assert leaf_qtys.get(egg["id"]) == 0.75
|
||||
# apple: 100 * 1.5 = 150
|
||||
assert leaf_qtys.get(apple["id"]) == 150.0
|
||||
|
||||
# Summary unchanged
|
||||
summary_after = _get_summary(client, date)
|
||||
assert summary_after["totals"]["calories"] == 228.0
|
||||
|
||||
|
||||
# ── POST /api/meals/{meal_id}/unpack rollback on failure ────────────────────
|
||||
|
||||
|
||||
def test_unpack_rollback_on_failure(client):
|
||||
"""Force a failure mid-transaction by trying to unpack with a bad entry_id
|
||||
→ nothing written, meal entry intact."""
|
||||
date = "2025-08-02"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Lunch", date, [e1["id"]])
|
||||
assert resp.status_code == 201
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
meal_entry_id = resp.json()["entry"]["id"]
|
||||
|
||||
# Try unpack with a non-existent entry_id
|
||||
bad_resp = _unpack_meal(client, meal_id, date, entry_id=99999)
|
||||
assert bad_resp.status_code == 404
|
||||
|
||||
# Meal entry still exists
|
||||
log_entries = _get_log(client, date)
|
||||
assert len(log_entries) == 1
|
||||
assert log_entries[0]["id"] == meal_entry_id
|
||||
|
||||
|
||||
def test_unpack_ambiguous_multiple_entries(client):
|
||||
"""When multiple log entries reference the same meal on the same date,
|
||||
unpack without entry_id returns 400."""
|
||||
date = "2025-08-03"
|
||||
|
||||
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Lunch", date, [e1["id"]])
|
||||
assert resp.status_code == 201
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
|
||||
# Log the meal again on the same date
|
||||
_log_entry(client, meal_id, quantity=1.0, date=date)
|
||||
|
||||
# Ambiguous unpack
|
||||
bad_resp = _unpack_meal(client, meal_id, date)
|
||||
assert bad_resp.status_code == 400, bad_resp.text
|
||||
assert "multiple" in bad_resp.json()["detail"].lower() or "ambiguous" in bad_resp.json()["detail"].lower()
|
||||
|
||||
# But unpack with explicit entry_id works
|
||||
entries = _get_log(client, date)
|
||||
for e in entries:
|
||||
if e["food_id"] == meal_id:
|
||||
ok_resp = _unpack_meal(client, meal_id, date, entry_id=e["id"])
|
||||
assert ok_resp.status_code == 200
|
||||
break
|
||||
|
||||
|
||||
# ── PUT /api/meals/{meal_id}/components cycle rejection ─────────────────────
|
||||
|
||||
|
||||
def test_components_cycle_rejection_transitive(client):
|
||||
"""Build meal A, meal B; try to set B's components to include A, then
|
||||
A's components to include B → 422 MealCycleError, graph unchanged."""
|
||||
date = "2025-08-04"
|
||||
|
||||
# Create base foods + two meals
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
# Create meal A with rice
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
resp_a = _from_log(client, "Meal A", date, _get_entry_ids(client, date))
|
||||
meal_a_id = resp_a.json()["meal"]["id"]
|
||||
|
||||
# Create meal B with chicken
|
||||
_log_entry(client, chicken["id"], quantity=200.0, date="2025-08-05")
|
||||
resp_b = _from_log(client, "Meal B", "2025-08-05", _get_entry_ids(client, "2025-08-05"))
|
||||
meal_b_id = resp_b.json()["meal"]["id"]
|
||||
|
||||
# Set meal A's components to include meal B
|
||||
resp = _update_components(client, meal_a_id, [
|
||||
{"food_id": meal_b_id, "quantity": 1.0},
|
||||
{"food_id": rice["id"], "quantity": 100.0},
|
||||
])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# Now try to set meal B's components to include meal A → cycle!
|
||||
resp = _update_components(client, meal_b_id, [
|
||||
{"food_id": meal_a_id, "quantity": 1.0},
|
||||
{"food_id": chicken["id"], "quantity": 200.0},
|
||||
])
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
# Meal B's components unchanged (still just chicken)
|
||||
meal_b = client.get(f"/api/foods/{meal_b_id}").json()
|
||||
b_food_ids = {c["food_id"] for c in meal_b["components"]}
|
||||
assert b_food_ids == {chicken["id"]}
|
||||
|
||||
|
||||
def test_components_direct_self_reference(client):
|
||||
"""A meal trying to include itself → 422."""
|
||||
date = "2025-08-06"
|
||||
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
resp = _from_log(client, "Self Meal", date, _get_entry_ids(client, date))
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
|
||||
# Try to make meal include itself
|
||||
resp = _update_components(client, meal_id, [
|
||||
{"food_id": rice["id"], "quantity": 100.0},
|
||||
{"food_id": meal_id, "quantity": 1.0},
|
||||
])
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_components_valid_non_cyclic_replacement(client):
|
||||
"""Valid non-cyclic replacement → 200, components replaced."""
|
||||
date = "2025-08-07"
|
||||
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
|
||||
|
||||
# Create meal with rice + chicken
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
_log_entry(client, chicken["id"], quantity=200.0, date=date)
|
||||
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
|
||||
# Verify initial components
|
||||
meal = client.get(f"/api/foods/{meal_id}").json()
|
||||
initial_food_ids = {c["food_id"] for c in meal["components"]}
|
||||
assert initial_food_ids == {rice["id"], chicken["id"]}
|
||||
|
||||
# Replace components with chicken + egg
|
||||
resp = _update_components(client, meal_id, [
|
||||
{"food_id": chicken["id"], "quantity": 150.0},
|
||||
{"food_id": egg["id"], "quantity": 2.0},
|
||||
])
|
||||
assert resp.status_code == 200, resp.text
|
||||
updated_meal = resp.json()
|
||||
new_food_ids = {c["food_id"] for c in updated_meal["components"]}
|
||||
assert new_food_ids == {chicken["id"], egg["id"]}
|
||||
assert updated_meal["is_meal"] is True
|
||||
|
||||
|
||||
def test_components_meal_not_found(client):
|
||||
"""PUT components on non-existent meal → 404."""
|
||||
resp = _update_components(client, 99999, [
|
||||
{"food_id": 1, "quantity": 1.0},
|
||||
])
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_components_food_not_found(client):
|
||||
"""PUT components referencing non-existent food → 404."""
|
||||
date = "2025-08-08"
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
|
||||
resp = _update_components(client, meal_id, [
|
||||
{"food_id": 99999, "quantity": 1.0},
|
||||
])
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── Summary: meals contribute real nutrition ────────────────────────────────
|
||||
|
||||
|
||||
def test_summary_meal_contributes_real_nutrition(client):
|
||||
"""Summary after from-log should show real derived nutrition (not 0)."""
|
||||
date = "2025-08-09"
|
||||
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight",
|
||||
protein_per_unit=2.7, carbs_per_unit=28.0, fat_per_unit=0.3)
|
||||
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight",
|
||||
protein_per_unit=31.0, carbs_per_unit=0.0, fat_per_unit=3.6)
|
||||
|
||||
_log_entry(client, rice["id"], quantity=200.0, date=date)
|
||||
_log_entry(client, chicken["id"], quantity=150.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
|
||||
assert resp.status_code == 201
|
||||
|
||||
summary = _get_summary(client, date)
|
||||
# rice: 130*200/100=260 kcal, 2.7*200/100=5.4g protein, 28*200/100=56g carbs, 0.3*200/100=0.6g fat
|
||||
# chicken: 165*150/100=247.5 kcal, 31*150/100=46.5g protein, 0 carbs, 3.6*150/100=5.4g fat
|
||||
# total: 507.5 kcal, 51.9g protein, 56g carbs, 6.0g fat
|
||||
assert abs(summary["totals"]["calories"] - 507.5) < 0.01
|
||||
assert abs(summary["totals"]["protein_g"] - 51.9) < 0.01
|
||||
assert abs(summary["totals"]["carbs_g"] - 56.0) < 0.01
|
||||
assert abs(summary["totals"]["fat_g"] - 6.0) < 0.01
|
||||
|
||||
|
||||
# ── GET /api/foods/{id} for meals ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_food_for_meal_returns_meal_read(client):
|
||||
"""GET /api/foods/{id} for a meal returns components + computed nutrition."""
|
||||
date = "2025-08-10"
|
||||
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
|
||||
|
||||
_log_entry(client, rice["id"], quantity=100.0, date=date)
|
||||
_log_entry(client, chicken["id"], quantity=200.0, date=date)
|
||||
|
||||
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
|
||||
meal_id = resp.json()["meal"]["id"]
|
||||
|
||||
meal = client.get(f"/api/foods/{meal_id}").json()
|
||||
assert meal["is_meal"] is True
|
||||
assert "components" in meal
|
||||
assert len(meal["components"]) == 2
|
||||
assert "computed_nutrition_per_meal" in meal
|
||||
# rice 100g (130) + chicken 200g (330) = 460
|
||||
assert abs(meal["computed_nutrition_per_meal"]["calories"] - 460.0) < 0.01
|
||||
|
||||
|
||||
def test_get_food_for_non_meal_returns_food_read(client):
|
||||
"""GET /api/foods/{id} for a non-meal returns plain FoodRead (no components)."""
|
||||
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
|
||||
food = client.get(f"/api/foods/{rice['id']}").json()
|
||||
assert food["is_meal"] is False
|
||||
# Plain FoodRead shouldn't have components or computed_nutrition_per_meal
|
||||
# (they might be present as empty/default — that's fine; key is it works)
|
||||
assert food["name"] == "Rice"
|
||||
|
||||
|
||||
# ── Helper: get all entry IDs for a date ─────────────────────────────────────
|
||||
|
||||
|
||||
def _get_entry_ids(client, date: str) -> list[int]:
|
||||
entries = client.get("/api/log", params={"date": date}).json()
|
||||
return [e["id"] for e in entries]
|
||||
Reference in New Issue
Block a user