From e3ca8b7a9670b923bfd72e935601ea062c3043b9 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Thu, 7 May 2026 06:40:36 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20orchestrator=20runner=20=E2=80=94=20run?= =?UTF-8?q?=5Fstep=20/=20run=5Fweek=20per-family=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/services/orchestrator/__init__.py | 3 + backend/app/services/orchestrator/runner.py | 83 +++++++++++++++++++ backend/tests/test_orchestrator.py | 42 ++++++++++ 3 files changed, 128 insertions(+) create mode 100644 backend/app/services/orchestrator/runner.py diff --git a/backend/app/services/orchestrator/__init__.py b/backend/app/services/orchestrator/__init__.py index e69de29..17fe704 100644 --- a/backend/app/services/orchestrator/__init__.py +++ b/backend/app/services/orchestrator/__init__.py @@ -0,0 +1,3 @@ +from app.services.orchestrator.runner import run_step, run_week + +__all__ = ["run_step", "run_week"] diff --git a/backend/app/services/orchestrator/runner.py b/backend/app/services/orchestrator/runner.py new file mode 100644 index 0000000..d021443 --- /dev/null +++ b/backend/app/services/orchestrator/runner.py @@ -0,0 +1,83 @@ +""" +Orchestrator runner. + +run_step(step_name, week_start_date) — execute one step for all families +run_week(week_start_date) — execute all steps in sequence +""" +from __future__ import annotations + +import logging +from datetime import date, timedelta +from typing import Optional + +from app.database import SessionLocal + +logger = logging.getLogger(__name__) + +STEPS = ("scrape", "generate", "email", "deadline", "finalize") + + +def _current_week_start() -> date: + """Return the most recent Friday (today if today is Friday).""" + today = date.today() + days_since_friday = (today.weekday() - 4) % 7 + return today - timedelta(days=days_since_friday) + + +def _get_or_create_run(db, family_id, week_start_date: date): + from app.models import WeeklyRun + + run = ( + db.query(WeeklyRun) + .filter( + WeeklyRun.family_id == family_id, + WeeklyRun.week_start_date == week_start_date, + ) + .first() + ) + if run is None: + run = WeeklyRun( + family_id=family_id, + week_start_date=week_start_date, + status="pending", + ) + db.add(run) + db.flush() + db.refresh(run) + return run + + +def run_step(step_name: str, week_start_date: Optional[date] = None) -> None: + from app.models import FamilyProfile + from app.services.orchestrator import steps as s + + step_fns = { + "scrape": s.step_scrape, + "generate": s.step_generate, + "email": s.step_email, + "deadline": s.step_deadline, + "finalize": s.step_finalize, + } + if step_name not in step_fns: + raise ValueError(f"Unknown step: {step_name!r}. Valid: {list(step_fns)}") + + week = week_start_date or _current_week_start() + db = SessionLocal() + families = db.query(FamilyProfile).all() + if not families: + logger.warning("run_step(%s): no family profiles, skipping", step_name) + return + for family in families: + run = _get_or_create_run(db, family.id, week) + try: + step_fns[step_name](run, db) + except Exception: + logger.exception( + "run_step(%s) failed for family %s", step_name, family.id + ) + + +def run_week(week_start_date: Optional[date] = None) -> None: + week = week_start_date or _current_week_start() + for step in STEPS: + run_step(step, week) diff --git a/backend/tests/test_orchestrator.py b/backend/tests/test_orchestrator.py index 4a1081d..f26ef62 100644 --- a/backend/tests/test_orchestrator.py +++ b/backend/tests/test_orchestrator.py @@ -423,3 +423,45 @@ def test_step_finalize_no_approved_sends_empty_message(db, weekly_run_deadline_p 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"] + + +# --------------------------------------------------------------------------- +# runner tests +# --------------------------------------------------------------------------- + +def test_run_step_upserts_weekly_run(db, family, monkeypatch): + monkeypatch.setattr( + "app.services.orchestrator.steps.ScraperService.run_scrape", + lambda self, **kw: {"status": "success", "items_scraped": 0}, + ) + monkeypatch.setattr( + "app.services.orchestrator.runner.SessionLocal", + lambda: db, + ) + from app.services.orchestrator.runner import run_step + run_step("scrape", WEEK) + from app.models import WeeklyRun + run = db.query(WeeklyRun).filter(WeeklyRun.family_id == family.id).first() + assert run is not None + assert run.scraped_at is not None + + +def test_run_step_invalid_raises(monkeypatch): + from app.services.orchestrator.runner import run_step + with pytest.raises(ValueError, match="Unknown step"): + run_step("bogus", WEEK) + + +def test_run_week_calls_all_steps(monkeypatch): + called = [] + + def fake_run_step(step, week): + called.append(step) + + monkeypatch.setattr( + "app.services.orchestrator.runner.run_step", + fake_run_step, + ) + from app.services.orchestrator.runner import run_week + run_week(WEEK) + assert called == ["scrape", "generate", "email", "deadline", "finalize"]