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