Public Access
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
353 lines
11 KiB
Python
353 lines
11 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, 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>"
|
|
)
|
|
|
|
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)
|
|
)
|
|
item_html_parts.append(
|
|
f'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
|
|
)
|
|
|
|
if not item_html_parts:
|
|
continue
|
|
|
|
email_html = (
|
|
f"<h2>This week's meal suggestions</h2>"
|
|
f"{stale_banner}"
|
|
f"<p>Hi {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>"
|
|
)
|
|
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)
|