Public Access
feat: orchestrator step_generate
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,3 +60,198 @@ def step_scrape(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
logger.warning("step_scrape: both attempts failed; marked stale")
|
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 = 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
|
||||||
|
|
||||||
|
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=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"<tr><td>{item.recipe.name}</td>"
|
||||||
|
f"<td>{ing.get('name', '')}</td>"
|
||||||
|
f"<td>{ing.get('qty', '')} {ing.get('unit', '')}</td></tr>"
|
||||||
|
for item in approved_items
|
||||||
|
if item.recipe
|
||||||
|
for ing in (item.recipe.ingredients or [])
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
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=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))
|
||||||
|
|||||||
@@ -205,3 +205,221 @@ def test_step_scrape_both_fail_marks_stale(db, weekly_run, monkeypatch):
|
|||||||
step_scrape(weekly_run, db)
|
step_scrape(weekly_run, db)
|
||||||
assert weekly_run.used_stale_data is True
|
assert weekly_run.used_stale_data is True
|
||||||
assert weekly_run.scraped_at is not None
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user