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 typing import TYPE_CHECKING
from app.config import settings 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.approval import issue_token
from app.services.email import get_email_backend from app.services.email import get_email_backend
from app.services.orchestrator.alerts import send_admin_alert 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>" "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() backend = get_email_backend()
for member in members: for member in members:
item_html_parts = [] item_html_parts = []
@@ -135,19 +186,70 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
recipe_name = html.escape( recipe_name = html.escape(
item.recipe.name if item.recipe else str(item.recipe_id) 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( 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: if not item_html_parts:
continue continue
email_html = ( email_html = (
f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>"
f"<h2>This week's meal suggestions</h2>" f"<h2>This week's meal suggestions</h2>"
f"{stale_banner}" f"{stale_banner}"
f"<p>Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT:</p>" f"<p>Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT. "
f"<ul>{''.join(item_html_parts)}</ul>" f"Silence = approved. Any denial removes that meal.</p>"
f"<p>Silence = approved. Any denial removes that meal.</p>" f"{''.join(item_html_parts)}"
f"{shopping_preview}"
f"</div>"
) )
backend.send( backend.send(
to=member.email, to=member.email,