Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
@@ -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", []),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user