feat(meals): enforce three lower-calorie dinners with media
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s

This commit is contained in:
2026-09-01 10:11:36 -07:00
parent 7838c49721
commit 2695ba0232
85 changed files with 8103 additions and 795 deletions
+73 -4
View File
@@ -9,6 +9,7 @@ from app.models import (
MealPlan, MealPlanItem, MealPlanVote, Recipe, Ingredient,
FamilyProfile, FamilyMember, ApprovalToken, NeverSuggest, NeverSuggestReason,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus,
RecipeCatalogStatus,
)
from app.schemas import (
MealPlanResponse, MealPlanCreate,
@@ -154,6 +155,7 @@ def get_planned_meals(
if not meal_plan:
return None
_enrich_recipe_ingredient_names(db, meal_plan)
return meal_plan
@@ -170,6 +172,23 @@ def create_meal_plan(meal_plan_data: MealPlanCreate, db: Session = Depends(get_d
if existing:
raise HTTPException(status_code=400, detail="Meal plan for this week already exists")
recipe_ids = {item.recipe_id for item in meal_plan_data.items}
if recipe_ids:
active_recipe_ids = {
recipe_id
for (recipe_id,) in db.query(Recipe.id)
.filter(
Recipe.id.in_(recipe_ids),
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
)
.all()
}
if active_recipe_ids != recipe_ids:
raise HTTPException(
status_code=400,
detail="Meal plan contains unavailable recipes",
)
db_meal_plan = MealPlan(
family_profile_id=profile.id,
week_start_date=meal_plan_data.week_start_date,
@@ -201,6 +220,7 @@ def get_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
if not meal_plan:
raise HTTPException(status_code=404, detail="Meal plan not found")
_enrich_recipe_ingredient_names(db, meal_plan)
return meal_plan
@@ -221,6 +241,39 @@ _DAY_NAMES = {
}
def _enrich_recipe_ingredient_names(db: Session, meal_plan: MealPlan) -> None:
"""Mutate each embedded Recipe's `ingredients` JSONB in-place to add
a resolved `name` from the canonical Ingredient table. Mirrors the
enrichment the public /api/recipes endpoints do; the meal-plan path
uses pydantic from_attributes which reads the raw JSONB and would
otherwise leave `name=None`. Idempotent: only sets name when
missing.
"""
recipes: list[Recipe] = []
for item in meal_plan.items:
if item.recipe is not None and item.recipe not in recipes:
recipes.append(item.recipe)
if not recipes:
return
all_ids: set = set()
for r in recipes:
for ref in (r.ingredients or []):
iid = ref.get("ingredient_id") if isinstance(ref, dict) else None
if iid:
all_ids.add(iid)
if not all_ids:
return
rows = db.query(Ingredient.id, Ingredient.name).filter(Ingredient.id.in_(all_ids)).all()
name_map = {str(rid): rname for rid, rname in rows}
for r in recipes:
for ref in (r.ingredients or []):
if not isinstance(ref, dict):
continue
iid = str(ref.get("ingredient_id") or "")
if iid and not ref.get("name"):
ref["name"] = name_map.get(iid)
class VoteSubmission(BaseModel):
"""Body for POST /vote/{item_id}?token=...
@@ -556,7 +609,10 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
new_recipe = db.query(Recipe).filter(Recipe.id == new_recipe_id).first()
new_recipe = db.query(Recipe).filter(
Recipe.id == new_recipe_id,
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
).first()
if not new_recipe:
raise HTTPException(status_code=404, detail="New recipe not found")
@@ -668,7 +724,17 @@ def generate_single_item(
raise HTTPException(status_code=400, detail="Slot already occupied")
# Get all recipes, score them, pick the best not already in this plan
all_recipes = db.query(Recipe).all()
all_recipes = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
).all()
if not all_recipes:
raise HTTPException(
status_code=409,
detail=(
"No active recipes are available. "
"Approve a staged recipe before generating meals."
),
)
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
# Simple heuristic: pick a random un-used recipe (we can improve scoring later)
@@ -728,7 +794,9 @@ def fill_empty_slots(
if not requested:
return FillEmptySlotsResult(filled=[], failed=[])
all_recipes = db.query(Recipe).all()
all_recipes = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
).all()
if not all_recipes:
# No recipes at all — every requested slot fails.
return FillEmptySlotsResult(
@@ -877,6 +945,7 @@ def discovered_count(db: Session = Depends(get_db)):
db.query(Recipe)
.filter(
Recipe.family_profile_id == profile.id,
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
Recipe.external_source.isnot(None),
Recipe.created_at >= since,
)
@@ -908,4 +977,4 @@ def discovery_insights(db: Session = Depends(get_db)):
"positive_signals": analysis.get("positive_signals", []),
"negative_signals": analysis.get("negative_signals", []),
"top_rated_recipes": analysis.get("top_rated_recipes", []),
}
}