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

This commit is contained in:
Craig
2026-07-26 17:27:15 +01:00
parent 4dd44b08d0
commit a8aed9a84f
9 changed files with 1720 additions and 73 deletions
+91
View File
@@ -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))