Files
Meal-Planner/backend/app/services/orchestrator/steps.py
T
2026-05-09 13:40:05 -07:00

455 lines
16 KiB
Python

"""
Orchestrator step functions.
Each step receives a WeeklyRun row and an open Session. Steps are idempotent:
a non-null timestamp column causes an immediate return. Steps commit their own
changes before returning.
All service imports are at module level so tests can monkeypatch via
`app.services.orchestrator.steps.<name>`.
"""
from __future__ import annotations
import html
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from app.config import settings
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
from app.services.planner.generate import generate_meal_plan
from app.services.scraper_service import ScraperService
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from app.models import WeeklyRun
logger = logging.getLogger(__name__)
def step_scrape(run: "WeeklyRun", db: "Session") -> None:
if run.scraped_at is not None:
logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
return
service = ScraperService(db)
last_result: dict = {}
for attempt in (1, 2):
last_result = service.run_scrape()
if last_result["status"] == "success":
run.scraped_at = datetime.now(timezone.utc)
run.used_stale_data = False
db.commit()
logger.info("step_scrape: success on attempt %d", attempt)
return
logger.warning(
"step_scrape: attempt %d failed: %s", attempt, last_result.get("error")
)
# Both attempts failed — mark stale and continue the cycle
run.scraped_at = datetime.now(timezone.utc)
run.used_stale_data = True
db.commit()
send_admin_alert(
subject=f"Scrape failed week {run.week_start_date}",
body=(
f"Both scrape attempts failed. Proceeding with stale grocery data.\n"
f"Error: {last_result.get('error')}"
),
)
logger.warning("step_scrape: both attempts failed; marked stale")
def step_generate(run: "WeeklyRun", db: "Session") -> None:
if run.generated_at is not None:
logger.info("step_generate: already done for %s", run.week_start_date)
return
try:
result = generate_meal_plan(
db,
family_id=run.family_id,
week_start_date=run.week_start_date,
)
run.generated_at = datetime.now(timezone.utc)
db.commit()
logger.info("step_generate: meal_plan %s created", result.meal_plan_id)
except Exception as exc:
run.error_step = "generate"
run.error_message = str(exc)
db.commit()
send_admin_alert(
subject=f"Generate failed week {run.week_start_date}",
body=str(exc),
)
raise
def step_email(run: "WeeklyRun", db: "Session") -> None:
if run.emailed_at is not None:
logger.info("step_email: already done for %s", run.week_start_date)
return
plan = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == run.family_id,
MealPlan.week_start_date == run.week_start_date,
)
.first()
)
if plan is None:
raise RuntimeError(
f"No MealPlan for family {run.family_id} week {run.week_start_date}"
)
members = (
db.query(FamilyMember)
.filter(
FamilyMember.family_profile_id == run.family_id,
FamilyMember.email.isnot(None),
)
.all()
)
stale_banner = ""
if run.used_stale_data:
stale_banner = (
"<p><strong>Note:</strong> Grocery prices in this plan may be a few "
"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 = []
for item in plan.items:
if item.approval_status != MealPlanItemStatus.PENDING:
continue
token = issue_token(item.id, member.id)
vote_url = (
f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
)
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'<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. "
f"Silence = approved. Any denial removes that meal.</p>"
f"{''.join(item_html_parts)}"
f"{shopping_preview}"
f"</div>"
)
backend.send(
to=member.email,
subject=f"Meal plan for week of {run.week_start_date}",
html=email_html,
)
logger.info("step_email: sent to %s", member.email)
run.emailed_at = datetime.now(timezone.utc)
db.commit()
def step_deadline(run: "WeeklyRun", db: "Session") -> None:
if run.deadline_passed_at is not None:
logger.info("step_deadline: already done for %s", run.week_start_date)
return
plan = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == run.family_id,
MealPlan.week_start_date == run.week_start_date,
)
.first()
)
if plan is None:
run.deadline_passed_at = datetime.now(timezone.utc)
db.commit()
return
family = db.query(FamilyProfile).filter(FamilyProfile.id == run.family_id).first()
policy = family.pending_approval_policy if family else "approve"
resolved = 0
for item in plan.items:
if item.approval_status == MealPlanItemStatus.PENDING:
item.approval_status = (
MealPlanItemStatus.APPROVED
if policy == "approve"
else MealPlanItemStatus.DENIED
)
resolved += 1
run.deadline_passed_at = datetime.now(timezone.utc)
db.commit()
logger.info(
"step_deadline: resolved %d pending items with policy=%s", resolved, policy
)
def step_finalize(run: "WeeklyRun", db: "Session") -> None:
if run.finalized_at is not None:
logger.info("step_finalize: already done for %s", run.week_start_date)
return
plan = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == run.family_id,
MealPlan.week_start_date == run.week_start_date,
)
.first()
)
approved_items = [
item
for item in (plan.items if plan else [])
if item.approval_status == MealPlanItemStatus.APPROVED
]
members = (
db.query(FamilyMember)
.filter(
FamilyMember.family_profile_id == run.family_id,
FamilyMember.email.isnot(None),
)
.all()
)
if approved_items:
rows_html = "".join(
f"<tr><td>{html.escape(item.recipe.name)}</td>"
f"<td>{html.escape(str(ing.get('name', '')))}</td>"
f"<td>{html.escape(str(ing.get('qty', '')))} {html.escape(str(ing.get('unit', '')))}</td></tr>"
for item in approved_items
if item.recipe
for ing in (item.recipe.ingredients or [])
)
email_html = (
f"<h2>Shopping list — week of {run.week_start_date}</h2>"
f"<p>{len(approved_items)} meal(s) approved.</p>"
f"<table><thead><tr><th>Recipe</th><th>Ingredient</th><th>Qty</th></tr></thead>"
f"<tbody>{rows_html}</tbody></table>"
)
else:
email_html = f"<p>No meals were approved for week of {run.week_start_date}.</p>"
backend = get_email_backend()
for member in members:
backend.send(
to=member.email,
subject=f"Shopping list — week of {run.week_start_date}",
html=email_html,
)
logger.info("step_finalize: shopping list sent to %s", member.email)
run.finalized_at = datetime.now(timezone.utc)
run.status = "completed"
db.commit()
logger.info("step_finalize: done, %d approved meals", len(approved_items))
def step_reminder(run: "WeeklyRun", db: "Session") -> None:
if run.reminded_at is not None:
logger.info("step_reminder: already done for %s", run.week_start_date)
return
if run.emailed_at is None:
logger.info("step_reminder: proposal not sent yet for %s, skipping", run.week_start_date)
return
plan = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == run.family_id,
MealPlan.week_start_date == run.week_start_date,
)
.first()
)
if plan is None:
run.reminded_at = datetime.now(timezone.utc)
db.commit()
return
pending_item_ids = [
item.id
for item in plan.items
if item.approval_status == MealPlanItemStatus.PENDING
]
if not pending_item_ids:
run.reminded_at = datetime.now(timezone.utc)
db.commit()
return
members = (
db.query(FamilyMember)
.filter(
FamilyMember.family_profile_id == run.family_id,
FamilyMember.email.isnot(None),
)
.all()
)
backend = get_email_backend()
for member in members:
voted_ids = {
v.meal_plan_item_id
for v in db.query(MealPlanVote)
.filter(
MealPlanVote.meal_plan_item_id.in_(pending_item_ids),
MealPlanVote.family_member_id == member.id,
)
.all()
}
unvoted = [
item
for item in plan.items
if item.approval_status == MealPlanItemStatus.PENDING
and item.id not in voted_ids
]
if not unvoted:
continue
item_html_parts = []
for item in unvoted:
token = issue_token(item.id, member.id)
vote_url = (
f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
)
recipe_name = html.escape(
item.recipe.name if item.recipe else str(item.recipe_id)
)
item_html_parts.append(
f'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
)
email_html = (
f"<h2>Vote closes in 1 hour!</h2>"
f"<p>Hi {html.escape(member.name)}, the meal plan vote closes at Fri 17:00 PT. "
f"You haven't voted on:</p>"
f"<ul>{''.join(item_html_parts)}</ul>"
)
backend.send(
to=member.email,
subject=f"Meal plan vote closes in 1 hour — week of {run.week_start_date}",
html=email_html,
)
logger.info("step_reminder: sent to %s", member.email)
run.reminded_at = datetime.now(timezone.utc)
db.commit()
logger.info("step_reminder: done for %s", run.week_start_date)