""" 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.`. """ from __future__ import annotations 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 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 = ( "

Note: Grocery prices in this plan may be a few " "days old — the Friday scrape failed and stale data was used.

" ) 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 = item.recipe.name if item.recipe else str(item.recipe_id) item_html_parts.append( f'
  • {recipe_name} — Vote
  • ' ) if not item_html_parts: continue html = ( f"

    This week's meal suggestions

    " f"{stale_banner}" f"

    Hi {member.name}, please vote on this week's meals by Fri 17:00 PT:

    " f"" f"

    Silence = approved. Any denial removes that meal.

    " ) backend.send( to=member.email, subject=f"Meal plan for week of {run.week_start_date}", html=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 = getattr(family, "pending_approval_policy", "approve") 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"{item.recipe.name}" f"{ing.get('name', '')}" f"{ing.get('qty', '')} {ing.get('unit', '')}" for item in approved_items if item.recipe for ing in (item.recipe.ingredients or []) ) html = ( f"

    Shopping list — week of {run.week_start_date}

    " f"

    {len(approved_items)} meal(s) approved.

    " f"" f"{rows_html}
    RecipeIngredientQty
    " ) else: html = f"

    No meals were approved for week of {run.week_start_date}.

    " backend = get_email_backend() for member in members: backend.send( to=member.email, subject=f"Shopping list — week of {run.week_start_date}", html=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))