Public Access
636 lines
24 KiB
Python
636 lines
24 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
|
|
import uuid as _uuid
|
|
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
|
|
from app.utils.units import convert_qty
|
|
|
|
from app.services.feedback_analyzer import FeedbackAnalyzer
|
|
from app.services.recipe_discovery import RecipeDiscoveryService
|
|
from app.services.recipe_ingestion import RecipeIngestionService
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.orm import Session
|
|
from app.models import WeeklyRun
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _suggested_sides_html(components: dict | None) -> str:
|
|
sides = (components or {}).get("suggested_sides")
|
|
if not isinstance(sides, dict):
|
|
return ""
|
|
items = sides.get("items")
|
|
if isinstance(items, list) and items:
|
|
text = "Pair with " + " + ".join(str(item) for item in items[:2])
|
|
else:
|
|
pair = [sides.get("vegetable"), sides.get("carb")]
|
|
pair = [str(part) for part in pair if part]
|
|
if not pair:
|
|
return ""
|
|
text = "Add " + " + ".join(pair)
|
|
return (
|
|
"<p style='font-size:13px;background:#ecfdf5;color:#065f46;"
|
|
"padding:8px;border-radius:6px;margin:8px 0'>"
|
|
f"{html.escape(text)}</p>"
|
|
)
|
|
|
|
|
|
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>"
|
|
)
|
|
|
|
# Preload ingredient names for all pending items
|
|
ing_ids: set = set()
|
|
for _item in plan.items:
|
|
if _item.approval_status == MealPlanItemStatus.pending and _item.recipe:
|
|
for _ing in (_item.recipe.ingredients or []):
|
|
if "ingredient_id" in _ing:
|
|
ing_ids.add(_ing["ingredient_id"])
|
|
|
|
ingredient_names: dict = {}
|
|
if ing_ids:
|
|
_ing_rows = db.query(Ingredient).filter(
|
|
Ingredient.id.in_([_uuid.UUID(str(i)) for i in ing_ids])
|
|
).all()
|
|
ingredient_names = {str(r.id): r.name for r in _ing_rows}
|
|
|
|
# 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 = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "unknown")).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(str(qty))} {html.escape(str(unit))}</td>"
|
|
f"<td style='padding:4px 8px;color:#555'>{html.escape(str(grocery))}</td>"
|
|
f"<td style='padding:4px 8px;font-weight:bold'>{html.escape(str(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(ingredient_names.get(str(ing.get('ingredient_id', '')), ing.get('name', 'unknown')))}</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 ""
|
|
)
|
|
|
|
# Cooking instructions (collapsed by default)
|
|
instructions = item.recipe.instructions or [] if item.recipe else []
|
|
instr_rows = "".join(
|
|
f"<li style='margin-bottom:4px'>{html.escape(str(step))}</li>"
|
|
for step in instructions
|
|
)
|
|
instructions_block = (
|
|
f"<details style='margin-top:8px'>"
|
|
f"<summary style='font-size:13px;color:#374151;cursor:pointer'>"
|
|
f"Cooking steps ({len(instructions)})</summary>"
|
|
f"<ol style='margin:8px 0;padding-left:20px;font-size:13px;color:#555'>{instr_rows}</ol>"
|
|
f"</details>"
|
|
if instr_rows else ""
|
|
)
|
|
|
|
# Estimated cost: sum top-confidence grocery match prices ÷ servings
|
|
est_cost_total = 0.0
|
|
for ing in ingredients:
|
|
ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), 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:
|
|
qty = ing.get("qty", 1.0)
|
|
unit = ing.get("unit", "")
|
|
gunit = match.grocery_item.unit or ""
|
|
converted = convert_qty(
|
|
qty,
|
|
unit,
|
|
gunit,
|
|
ingredient_name_lower=ing_name,
|
|
)
|
|
est_cost_total += float(match.grocery_item.current_price) * float(converted)
|
|
|
|
recipe_servings = (item.recipe.servings or 4) if item.recipe else 4
|
|
est_cost_per_serving = est_cost_total / recipe_servings
|
|
|
|
cost_block = (
|
|
f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
|
|
if est_cost_total > 0 else ""
|
|
)
|
|
sides_block = _suggested_sides_html(item.components)
|
|
|
|
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'{instructions_block}'
|
|
f'{sides_block}'
|
|
f'{cost_block}'
|
|
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
|
|
f'<a href="{vote_url}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
|
f'background:#16a34a;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
|
|
f'Approve</a>'
|
|
f'<a href="{vote_url}&scope=this_week" style="display:inline-block;padding:8px 14px;'
|
|
f'background:#dc2626;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
|
|
f'Deny this week</a>'
|
|
f'<a href="{vote_url}&scope=never_again" style="display:inline-block;padding:8px 14px;'
|
|
f'background:#7f1d1d;color:white;text-decoration:none;border-radius:6px;font-size:14px;'
|
|
f'border:1px dashed #fca5a5">'
|
|
f'Never again</a>'
|
|
f'</div>'
|
|
f'<div style="font-size:11px;color:#888;margin-top:4px">'
|
|
f'<a href="{vote_url}" style="color:#2563eb">Open vote page (all 3 options)</a></div>'
|
|
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
|
|
|
|
import uuid as _uuid_fin
|
|
|
|
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()
|
|
)
|
|
|
|
# Preload ingredient names for approved items
|
|
_fin_ids: set = set()
|
|
for _item in approved_items:
|
|
if _item.recipe:
|
|
for _ing in (_item.recipe.ingredients or []):
|
|
if "ingredient_id" in _ing:
|
|
_fin_ids.add(_ing["ingredient_id"])
|
|
_fin_names: dict = {}
|
|
if _fin_ids:
|
|
_fin_rows = db.query(Ingredient).filter(
|
|
Ingredient.id.in_([_uuid_fin.UUID(str(i)) for i in _fin_ids])
|
|
).all()
|
|
_fin_names = {str(r.id): r.name for r in _fin_rows}
|
|
|
|
if approved_items:
|
|
total_cost = 0.0
|
|
sections_html = ""
|
|
for item in approved_items:
|
|
if not item.recipe:
|
|
continue
|
|
recipe_name = html.escape(item.recipe.name)
|
|
rows_html = ""
|
|
for ing in (item.recipe.ingredients or []):
|
|
ing_id = str(ing.get("ingredient_id", ""))
|
|
ing_name = _fin_names.get(ing_id, "")
|
|
if not ing_name:
|
|
continue
|
|
qty = ing.get("qty", "")
|
|
unit = ing.get("unit", "")
|
|
|
|
match = (
|
|
db.query(IngredientGroceryMatch)
|
|
.join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
|
|
.filter(Ingredient.id == _uuid_fin.UUID(ing_id))
|
|
.order_by(IngredientGroceryMatch.confidence.desc())
|
|
.first()
|
|
)
|
|
if match and match.grocery_item:
|
|
g_item = match.grocery_item
|
|
lucky_name = html.escape(g_item.name or "")
|
|
price = float(g_item.current_price or 0)
|
|
qty = ing.get("qty", 1.0)
|
|
unit = ing.get("unit", "")
|
|
gunit = g_item.unit or ""
|
|
converted = convert_qty(
|
|
qty,
|
|
unit,
|
|
gunit,
|
|
ingredient_name_lower=ing_name,
|
|
)
|
|
total_cost += price * float(converted)
|
|
price_str = f"${price * float(converted):.2f}"
|
|
else:
|
|
lucky_name = "—"
|
|
price_str = "—"
|
|
|
|
rows_html += (
|
|
f"<tr>"
|
|
f"<td style='padding:6px 8px'>{html.escape(ing_name)}</td>"
|
|
f"<td style='padding:6px 8px;color:#555'>{html.escape(str(qty))} {html.escape(str(unit))}</td>"
|
|
f"<td style='padding:6px 8px;color:#555'>{lucky_name}</td>"
|
|
f"<td style='padding:6px 8px;font-weight:bold'>{price_str}</td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
sections_html += (
|
|
f"<h3 style='margin:20px 0 4px;font-size:15px'>{recipe_name}</h3>"
|
|
f"<table style='border-collapse:collapse;width:100%;font-size:13px;margin-bottom:8px'>"
|
|
f"<thead><tr style='background:#f3f4f6'>"
|
|
f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>Ingredient</th>"
|
|
f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>Qty</th>"
|
|
f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>At Lucky</th>"
|
|
f"<th style='text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb'>Price</th>"
|
|
f"</tr></thead>"
|
|
f"<tbody>{rows_html}</tbody>"
|
|
f"</table>"
|
|
)
|
|
|
|
email_html = (
|
|
f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>"
|
|
f"<h2>Shopping list — week of {run.week_start_date}</h2>"
|
|
f"<p>{len(approved_items)} meal(s) approved.</p>"
|
|
f"{sections_html}"
|
|
f"<p style='margin-top:16px;font-weight:bold'>Estimated total: ${total_cost:.2f}</p>"
|
|
f"</div>"
|
|
)
|
|
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"
|
|
|
|
# --- Feedback-driven recipe discovery ---
|
|
try:
|
|
analyzer = FeedbackAnalyzer(lookback_weeks=4)
|
|
analysis = analyzer.analyze(db, run.family_id)
|
|
run.feedback_analysis = analysis.to_dict()
|
|
|
|
if (
|
|
analysis.discovery_queries and
|
|
analysis.confidence >= 0.5
|
|
):
|
|
discovery = RecipeDiscoveryService()
|
|
candidates = discovery.discover(analysis.discovery_queries)
|
|
if candidates:
|
|
ingestion = RecipeIngestionService()
|
|
added = ingestion.ingest(db, run.family_id, candidates, analysis.to_dict())
|
|
logger.info(
|
|
"step_finalize: recipe discovery added %d new recipes for family %s",
|
|
added, run.family_id,
|
|
)
|
|
# ingestion deliberately does not commit so orchestrator
|
|
# can keep everything in one transaction
|
|
db.commit()
|
|
except Exception as exc:
|
|
# Discovery is best-effort; never block finalization
|
|
logger.warning("step_finalize: recipe discovery failed: %s", exc)
|
|
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)
|