diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py index a07b415..1bb55e4 100644 --- a/backend/app/services/orchestrator/steps.py +++ b/backend/app/services/orchestrator/steps.py @@ -60,3 +60,198 @@ def step_scrape(run: "WeeklyRun", db: "Session") -> None: ), ) 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)) diff --git a/backend/tests/test_orchestrator.py b/backend/tests/test_orchestrator.py index 0e84bd4..4a1081d 100644 --- a/backend/tests/test_orchestrator.py +++ b/backend/tests/test_orchestrator.py @@ -205,3 +205,221 @@ def test_step_scrape_both_fail_marks_stale(db, weekly_run, monkeypatch): step_scrape(weekly_run, db) assert weekly_run.used_stale_data is True assert weekly_run.scraped_at is not None + + +# --------------------------------------------------------------------------- +# step_generate tests +# --------------------------------------------------------------------------- + +def test_step_generate_idempotent(db, weekly_run_generated): + original_ts = weekly_run_generated.generated_at + from app.services.orchestrator.steps import step_generate + step_generate(weekly_run_generated, db) + assert weekly_run_generated.generated_at == original_ts + + +def test_step_generate_creates_plan(db, weekly_run_scraped, monkeypatch): + from uuid import uuid4 + from app.services.planner.types import GenerationResult + + monkeypatch.setattr( + "app.services.orchestrator.steps.generate_meal_plan", + lambda db, **kw: GenerationResult( + meal_plan_id=uuid4(), + selected=[], + feasible_count=0, + rejected_summary={}, + set_score=0.0, + ), + ) + from app.services.orchestrator.steps import step_generate + step_generate(weekly_run_scraped, db) + assert weekly_run_scraped.generated_at is not None + + +def test_step_generate_records_error_on_failure(db, weekly_run_scraped, monkeypatch): + monkeypatch.setattr( + "app.services.orchestrator.steps.generate_meal_plan", + lambda db, **kw: (_ for _ in ()).throw(RuntimeError("no recipes")), + ) + monkeypatch.setattr( + "app.services.orchestrator.steps.send_admin_alert", + lambda subject, body: None, + ) + from app.services.orchestrator.steps import step_generate + with pytest.raises(RuntimeError): + step_generate(weekly_run_scraped, db) + assert weekly_run_scraped.error_step == "generate" + + +# --------------------------------------------------------------------------- +# step_email tests +# --------------------------------------------------------------------------- + +def test_step_email_idempotent(db, weekly_run_emailed): + original_ts = weekly_run_emailed.emailed_at + from app.services.orchestrator.steps import step_email + step_email(weekly_run_emailed, db) + assert weekly_run_emailed.emailed_at == original_ts + + +def test_step_email_sends_per_member(db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch): + sent = [] + + class FakeBackend: + def send(self, **kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "app.services.orchestrator.steps.get_email_backend", + lambda: FakeBackend(), + ) + from app.services.orchestrator.steps import step_email + step_email(weekly_run_generated, db) + assert weekly_run_generated.emailed_at is not None + assert len(sent) == 1 + assert sent[0]["to"] == "alice@example.com" + assert str(WEEK) in sent[0]["subject"] + + +def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_item, member, monkeypatch): + weekly_run_generated.used_stale_data = True + sent = [] + + class FakeBackend: + def send(self, **kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "app.services.orchestrator.steps.get_email_backend", + lambda: FakeBackend(), + ) + from app.services.orchestrator.steps import step_email + step_email(weekly_run_generated, db) + assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower() + + +# --------------------------------------------------------------------------- +# step_deadline tests +# --------------------------------------------------------------------------- + +def test_step_deadline_idempotent(db, weekly_run_emailed): + weekly_run_emailed.deadline_passed_at = datetime.now(timezone.utc) + db.flush() + original_ts = weekly_run_emailed.deadline_passed_at + from app.services.orchestrator.steps import step_deadline + step_deadline(weekly_run_emailed, db) + assert weekly_run_emailed.deadline_passed_at == original_ts + + +def test_step_deadline_resolves_pending_to_approved(db, weekly_run_emailed, meal_plan, pending_item, family): + from app.models import MealPlanItemStatus + from app.services.orchestrator.steps import step_deadline + family.pending_approval_policy = "approve" + db.flush() + step_deadline(weekly_run_emailed, db) + db.refresh(pending_item) + assert pending_item.approval_status == MealPlanItemStatus.APPROVED + assert weekly_run_emailed.deadline_passed_at is not None + + +def test_step_deadline_resolves_pending_to_denied(db, weekly_run_emailed, meal_plan, pending_item, family): + from app.models import MealPlanItemStatus + from app.services.orchestrator.steps import step_deadline + family.pending_approval_policy = "deny" + db.flush() + step_deadline(weekly_run_emailed, db) + db.refresh(pending_item) + assert pending_item.approval_status == MealPlanItemStatus.DENIED + + +# --------------------------------------------------------------------------- +# step_finalize tests +# --------------------------------------------------------------------------- + +@pytest.fixture() +def weekly_run_deadline_passed(db, family): + from app.models import WeeklyRun + r = WeeklyRun( + family_id=family.id, + week_start_date=WEEK, + status="running", + scraped_at=datetime.now(timezone.utc), + generated_at=datetime.now(timezone.utc), + emailed_at=datetime.now(timezone.utc), + deadline_passed_at=datetime.now(timezone.utc), + ) + db.add(r) + db.flush() + return r + + +@pytest.fixture() +def approved_item(db, meal_plan): + from app.models import MealPlanItem, MealPlanItemStatus, MealType, Recipe + recipe = Recipe( + id=uuid4(), + name="Approved Chicken", + servings=4, + ingredients=[{"name": "chicken", "qty": "500", "unit": "g"}], + instructions=["cook it"], + ) + db.add(recipe) + db.flush() + item = MealPlanItem( + id=uuid4(), + meal_plan_id=meal_plan.id, + recipe_id=recipe.id, + day_of_week=6, + meal_type=MealType.DINNER, + approval_status=MealPlanItemStatus.APPROVED, + ) + db.add(item) + db.flush() + return item + + +def test_step_finalize_idempotent(db, weekly_run_deadline_passed): + weekly_run_deadline_passed.finalized_at = datetime.now(timezone.utc) + db.flush() + original_ts = weekly_run_deadline_passed.finalized_at + from app.services.orchestrator.steps import step_finalize + step_finalize(weekly_run_deadline_passed, db) + assert weekly_run_deadline_passed.finalized_at == original_ts + + +def test_step_finalize_sends_shopping_list(db, weekly_run_deadline_passed, meal_plan, approved_item, member, monkeypatch): + sent = [] + + class FakeBackend: + def send(self, **kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "app.services.orchestrator.steps.get_email_backend", + lambda: FakeBackend(), + ) + from app.services.orchestrator.steps import step_finalize + step_finalize(weekly_run_deadline_passed, db) + assert weekly_run_deadline_passed.finalized_at is not None + assert weekly_run_deadline_passed.status == "completed" + assert len(sent) == 1 + assert "Shopping list" in sent[0]["subject"] + assert "chicken" in sent[0]["html"].lower() + + +def test_step_finalize_no_approved_sends_empty_message(db, weekly_run_deadline_passed, meal_plan, pending_item, member, monkeypatch): + sent = [] + + class FakeBackend: + def send(self, **kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "app.services.orchestrator.steps.get_email_backend", + lambda: FakeBackend(), + ) + from app.services.orchestrator.steps import step_finalize + step_finalize(weekly_run_deadline_passed, db) + assert weekly_run_deadline_passed.finalized_at is not None + assert "No meals were approved" in sent[0]["html"]