Public Access
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user