Public Access
feat: step_reminder — 1-hour pre-deadline nudge for non-voters
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,13 @@ All service imports are at module level so tests can monkeypatch via
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import FamilyMember, FamilyProfile, MealPlan, MealPlanItemStatus
|
from app.models import FamilyMember, FamilyProfile, MealPlan, MealPlanItemStatus, MealPlanVote
|
||||||
from app.services.approval import issue_token
|
from app.services.approval import issue_token
|
||||||
from app.services.email import get_email_backend
|
from app.services.email import get_email_backend
|
||||||
from app.services.orchestrator.alerts import send_admin_alert
|
from app.services.orchestrator.alerts import send_admin_alert
|
||||||
@@ -255,3 +256,95 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
run.status = "completed"
|
run.status = "completed"
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("step_finalize: done, %d approved meals", len(approved_items))
|
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)
|
||||||
|
|||||||
@@ -135,6 +135,23 @@ def weekly_run_emailed(db, family):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def weekly_run_reminded(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),
|
||||||
|
reminded_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(r)
|
||||||
|
db.flush()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# alerts.py tests
|
# alerts.py tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -299,6 +316,84 @@ def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_it
|
|||||||
assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()
|
assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── step_reminder ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_reminder_idempotent(db, weekly_run_reminded):
|
||||||
|
original_ts = weekly_run_reminded.reminded_at
|
||||||
|
from app.services.orchestrator.steps import step_reminder
|
||||||
|
step_reminder(weekly_run_reminded, db)
|
||||||
|
assert weekly_run_reminded.reminded_at == original_ts
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_reminder_skips_when_not_emailed(db, weekly_run_generated, 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_reminder
|
||||||
|
step_reminder(weekly_run_generated, db)
|
||||||
|
# emailed_at is None on weekly_run_generated — should be a no-op
|
||||||
|
assert len(sent) == 0
|
||||||
|
assert weekly_run_generated.reminded_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_reminder_sends_to_non_voter(
|
||||||
|
db, weekly_run_emailed, 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_reminder
|
||||||
|
step_reminder(weekly_run_emailed, db)
|
||||||
|
assert weekly_run_emailed.reminded_at is not None
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert sent[0]["to"] == "alice@example.com"
|
||||||
|
assert "1 hour" in sent[0]["subject"].lower() or "closes" in sent[0]["subject"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_reminder_skips_voter(
|
||||||
|
db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
|
||||||
|
):
|
||||||
|
from app.models import MealPlanVote
|
||||||
|
vote = MealPlanVote(
|
||||||
|
meal_plan_item_id=pending_item.id,
|
||||||
|
family_member_id=member.id,
|
||||||
|
vote=True,
|
||||||
|
)
|
||||||
|
db.add(vote)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
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_reminder
|
||||||
|
step_reminder(weekly_run_emailed, db)
|
||||||
|
# member already voted — no reminder
|
||||||
|
assert len(sent) == 0
|
||||||
|
assert weekly_run_emailed.reminded_at is not None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# step_deadline tests
|
# step_deadline tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user