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