feat: enrich proposal email with ingredients, cost, shopping preview

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:40:05 -07:00
co-authored by Claude Sonnet 4.6
parent 43ee581931
commit 0739a688dd
+107 -5
View File
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING
from app.config import settings
from app.models import FamilyMember, FamilyProfile, MealPlan, MealPlanItemStatus, MealPlanVote
from app.models import FamilyMember, FamilyProfile, Ingredient, IngredientGroceryMatch, MealPlan, MealPlanItemStatus, MealPlanVote
from app.services.approval import issue_token
from app.services.email import get_email_backend
from app.services.orchestrator.alerts import send_admin_alert
@@ -122,6 +122,57 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
"days old — the Friday scrape failed and stale data was used.</p>"
)
# Build shopping list preview once (shared across all member emails)
all_ingredients: dict[str, tuple[str, str, str, str]] = {}
for item in plan.items:
if item.approval_status != MealPlanItemStatus.PENDING:
continue
for ing in (item.recipe.ingredients or [] if item.recipe else []):
ing_name = ing.get("name", "").strip()
if not ing_name or ing_name in all_ingredients:
continue
match = (
db.query(IngredientGroceryMatch)
.join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
.filter(Ingredient.name_lower == ing_name.lower())
.order_by(IngredientGroceryMatch.confidence.desc())
.first()
)
grocery_name = match.grocery_item.name if match and match.grocery_item else ""
price = (
f"${float(match.grocery_item.current_price):.2f}"
if match and match.grocery_item and match.grocery_item.current_price
else ""
)
all_ingredients[ing_name] = (
ing.get("qty", ""),
ing.get("unit", ""),
grocery_name,
price,
)
if all_ingredients:
shop_rows = "".join(
f"<tr><td style='padding:4px 8px'>{html.escape(name)}</td>"
f"<td style='padding:4px 8px;color:#555'>{html.escape(qty)} {html.escape(unit)}</td>"
f"<td style='padding:4px 8px;color:#555'>{html.escape(grocery)}</td>"
f"<td style='padding:4px 8px;font-weight:bold'>{html.escape(price)}</td></tr>"
for name, (qty, unit, grocery, price) in all_ingredients.items()
)
shopping_preview = (
f"<hr style='margin:24px 0'>"
f"<h3>Estimated shopping list</h3>"
f"<table style='border-collapse:collapse;font-size:13px;width:100%'>"
f"<thead><tr>"
f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>Ingredient</th>"
f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>Qty</th>"
f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>At Lucky</th>"
f"<th style='text-align:left;padding:4px 8px;border-bottom:1px solid #e5e7eb'>Price</th>"
f"</tr></thead><tbody>{shop_rows}</tbody></table>"
)
else:
shopping_preview = ""
backend = get_email_backend()
for member in members:
item_html_parts = []
@@ -135,19 +186,70 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
recipe_name = html.escape(
item.recipe.name if item.recipe else str(item.recipe_id)
)
# Image block
img_block = (
f'<img src="{html.escape(item.recipe.image_url)}" '
f'style="width:200px;height:140px;object-fit:cover;border-radius:8px;" alt="">'
if item.recipe and item.recipe.image_url else ""
)
# Ingredients list
ingredients = item.recipe.ingredients or [] if item.recipe else []
ing_rows = "".join(
f"<li>{html.escape(str(ing.get('qty', '')))}"
f" {html.escape(str(ing.get('unit', '')))}"
f" {html.escape(str(ing.get('name', '')))}</li>"
for ing in ingredients
)
ing_block = (
f"<ul style='margin:4px 0;padding-left:20px;font-size:13px;color:#555'>{ing_rows}</ul>"
if ing_rows else ""
)
# Estimated cost: sum top-confidence grocery match prices
est_cost = 0.0
for ing in ingredients:
ing_name = ing.get("name", "").lower()
match = (
db.query(IngredientGroceryMatch)
.join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
.filter(Ingredient.name_lower == ing_name)
.order_by(IngredientGroceryMatch.confidence.desc())
.first()
)
if match and match.grocery_item and match.grocery_item.current_price:
est_cost += float(match.grocery_item.current_price)
cost_block = (
f"<p style='font-size:13px;color:#888'>Est. cost: ~${est_cost:.2f}</p>"
if est_cost > 0 else ""
)
item_html_parts.append(
f'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
f'{img_block}'
f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
f'{ing_block}'
f'{cost_block}'
f'<a href="{vote_url}" style="display:inline-block;margin-top:8px;padding:8px 16px;'
f'background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Vote on this meal</a>'
f'</div>'
)
if not item_html_parts:
continue
email_html = (
f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>"
f"<h2>This week's meal suggestions</h2>"
f"{stale_banner}"
f"<p>Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT:</p>"
f"<ul>{''.join(item_html_parts)}</ul>"
f"<p>Silence = approved. Any denial removes that meal.</p>"
f"<p>Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT. "
f"Silence = approved. Any denial removes that meal.</p>"
f"{''.join(item_html_parts)}"
f"{shopping_preview}"
f"</div>"
)
backend.send(
to=member.email,