Public Access
fix: test-email actually sends; vote page pre-checks existing votes; lowercase MealPlanItemStatus everywhere
- /api/admin/test-email now calls get_email_backend().send() instead of only logging.
- /api/meals/vote/{id} GET now queries MealPlanVote and renders 'already voted' confirmation if found.
- api/meals.py: fix remaining uppercase MealPlanItemStatus enum ref (DENIED, APPROVED, PENDING).
- Fixes the 'all meals show as pending' status regression and the 'Error: Already voted' bug.
This commit is contained in:
@@ -135,10 +135,27 @@ def get_all_meal_plans(
|
|||||||
|
|
||||||
@router.post("/test-email")
|
@router.post("/test-email")
|
||||||
def test_email(email: str, db: Session = Depends(get_db)):
|
def test_email(email: str, db: Session = Depends(get_db)):
|
||||||
|
from app.services.email import get_email_backend
|
||||||
|
|
||||||
|
html = "<html><body><h1>MealPlanner Test Email</h1><p>This is a test email from MealPlanner.</p></body></html>"
|
||||||
|
text = "MealPlanner Test Email\n\nThis is a test email from MealPlanner."
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend = get_email_backend()
|
||||||
|
backend.send(
|
||||||
|
to=email,
|
||||||
|
subject="MealPlanner Test Email",
|
||||||
|
html=html,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
status = "sent"
|
||||||
|
except Exception as e:
|
||||||
|
status = f"failed: {e}"
|
||||||
|
|
||||||
email_log = EmailLog(
|
email_log = EmailLog(
|
||||||
recipient_email=email,
|
recipient_email=email,
|
||||||
template="test",
|
template="test",
|
||||||
status="sent",
|
status=status,
|
||||||
created_at=datetime.now()
|
created_at=datetime.now()
|
||||||
)
|
)
|
||||||
db.add(email_log)
|
db.add(email_log)
|
||||||
@@ -146,9 +163,10 @@ def test_email(email: str, db: Session = Depends(get_db)):
|
|||||||
db.refresh(email_log)
|
db.refresh(email_log)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Test email logged",
|
"message": f"Test email {status}",
|
||||||
"email_log_id": str(email_log.id),
|
"email_log_id": str(email_log.id),
|
||||||
"recipient": email
|
"recipient": email,
|
||||||
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,38 @@ def get_vote_page(
|
|||||||
safe_meal = _html_escape(meal_type)
|
safe_meal = _html_escape(meal_type)
|
||||||
action_url = f"/api/meals/vote/{item_id}?token={_html_escape(token, quote=True)}"
|
action_url = f"/api/meals/vote/{item_id}?token={_html_escape(token, quote=True)}"
|
||||||
|
|
||||||
|
existing_vote = db.query(MealPlanVote).filter(
|
||||||
|
MealPlanVote.meal_plan_item_id == item_id,
|
||||||
|
MealPlanVote.family_member_id == voter.id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_vote:
|
||||||
|
already_voted_html = f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Already Voted</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
|
||||||
|
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
|
||||||
|
h1 {{ font-size: 1.4rem; }}
|
||||||
|
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
|
||||||
|
.already-voted {{ margin-top: 1rem; font-weight: bold; color: #0a6b2b; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hi {safe_voter}, you already voted on this meal</h1>
|
||||||
|
<div class="meal">
|
||||||
|
<div><strong>{safe_recipe}</strong></div>
|
||||||
|
<div>{safe_day} · {safe_meal}</div>
|
||||||
|
</div>
|
||||||
|
<div class="already-voted">Your vote has already been recorded. Thank you!</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=already_voted_html, status_code=200)
|
||||||
|
|
||||||
html = f"""<!doctype html>
|
html = f"""<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -251,11 +283,11 @@ def submit_vote(
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
if any(v.vote is False for v in votes):
|
if any(v.vote is False for v in votes):
|
||||||
item.approval_status = MealPlanItemStatus.DENIED
|
item.approval_status = MealPlanItemStatus.denied
|
||||||
elif electorate_ids and {v.family_member_id for v in votes} >= electorate_ids:
|
elif electorate_ids and {v.family_member_id for v in votes} >= electorate_ids:
|
||||||
item.approval_status = MealPlanItemStatus.APPROVED
|
item.approval_status = MealPlanItemStatus.approved
|
||||||
else:
|
else:
|
||||||
item.approval_status = MealPlanItemStatus.PENDING
|
item.approval_status = MealPlanItemStatus.pending
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "recorded", "item_status": item.approval_status.value}
|
return {"status": "recorded", "item_status": item.approval_status.value}
|
||||||
@@ -282,7 +314,7 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
|
|||||||
raise HTTPException(status_code=404, detail="New recipe not found")
|
raise HTTPException(status_code=404, detail="New recipe not found")
|
||||||
|
|
||||||
item.recipe_id = new_recipe_id
|
item.recipe_id = new_recipe_id
|
||||||
item.approval_status = MealPlanItemStatus.PENDING
|
item.approval_status = MealPlanItemStatus.pending
|
||||||
item.denial_reason = None
|
item.denial_reason = None
|
||||||
item.denial_details = None
|
item.denial_details = None
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ class MealPlanStatus(enum.Enum):
|
|||||||
LOCKED = "locked"
|
LOCKED = "locked"
|
||||||
|
|
||||||
|
|
||||||
class MealPlanItemStatus(enum.Enum):
|
class MealPlanItemStatus(str, enum.Enum):
|
||||||
PENDING = "pending"
|
pending = "pending"
|
||||||
APPROVED = "approved"
|
approved = "approved"
|
||||||
DENIED = "denied"
|
denied = "denied"
|
||||||
SWAPPED = "swapped"
|
swapped = "swapped"
|
||||||
|
|
||||||
|
|
||||||
class ApprovalTokenStatus(enum.Enum):
|
class ApprovalTokenStatus(enum.Enum):
|
||||||
@@ -221,7 +221,7 @@ class MealPlanItem(Base):
|
|||||||
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
|
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
|
||||||
day_of_week = Column(Integer, nullable=False)
|
day_of_week = Column(Integer, nullable=False)
|
||||||
meal_type = Column(SQLEnum(MealType, name="meal_type_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
|
meal_type = Column(SQLEnum(MealType, name="meal_type_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
|
||||||
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), default=MealPlanItemStatus.PENDING)
|
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), default=MealPlanItemStatus.pending)
|
||||||
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]))
|
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]))
|
||||||
denial_details = Column(Text)
|
denial_details = Column(Text)
|
||||||
estimated_cost = Column(Numeric(10, 2))
|
estimated_cost = Column(Numeric(10, 2))
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
# Preload ingredient names for all pending items
|
# Preload ingredient names for all pending items
|
||||||
ing_ids: set = set()
|
ing_ids: set = set()
|
||||||
for _item in plan.items:
|
for _item in plan.items:
|
||||||
if _item.approval_status == MealPlanItemStatus.PENDING and _item.recipe:
|
if _item.approval_status == MealPlanItemStatus.pending and _item.recipe:
|
||||||
for _ing in (_item.recipe.ingredients or []):
|
for _ing in (_item.recipe.ingredients or []):
|
||||||
if "ingredient_id" in _ing:
|
if "ingredient_id" in _ing:
|
||||||
ing_ids.add(_ing["ingredient_id"])
|
ing_ids.add(_ing["ingredient_id"])
|
||||||
@@ -141,7 +141,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
# Build shopping list preview once (shared across all member emails)
|
# Build shopping list preview once (shared across all member emails)
|
||||||
all_ingredients: dict[str, tuple[str, str, str, str]] = {}
|
all_ingredients: dict[str, tuple[str, str, str, str]] = {}
|
||||||
for item in plan.items:
|
for item in plan.items:
|
||||||
if item.approval_status != MealPlanItemStatus.PENDING:
|
if item.approval_status != MealPlanItemStatus.pending:
|
||||||
continue
|
continue
|
||||||
for ing in (item.recipe.ingredients or [] if item.recipe else []):
|
for ing in (item.recipe.ingredients or [] if item.recipe else []):
|
||||||
ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "unknown")).strip()
|
ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "unknown")).strip()
|
||||||
@@ -193,7 +193,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
for member in members:
|
for member in members:
|
||||||
item_html_parts = []
|
item_html_parts = []
|
||||||
for item in plan.items:
|
for item in plan.items:
|
||||||
if item.approval_status != MealPlanItemStatus.PENDING:
|
if item.approval_status != MealPlanItemStatus.pending:
|
||||||
continue
|
continue
|
||||||
token = issue_token(item.id, member.id)
|
token = issue_token(item.id, member.id)
|
||||||
vote_url = (
|
vote_url = (
|
||||||
@@ -317,11 +317,11 @@ def step_deadline(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
|
|
||||||
resolved = 0
|
resolved = 0
|
||||||
for item in plan.items:
|
for item in plan.items:
|
||||||
if item.approval_status == MealPlanItemStatus.PENDING:
|
if item.approval_status == MealPlanItemStatus.pending:
|
||||||
item.approval_status = (
|
item.approval_status = (
|
||||||
MealPlanItemStatus.APPROVED
|
MealPlanItemStatus.approved
|
||||||
if policy == "approve"
|
if policy == "approve"
|
||||||
else MealPlanItemStatus.DENIED
|
else MealPlanItemStatus.denied
|
||||||
)
|
)
|
||||||
resolved += 1
|
resolved += 1
|
||||||
|
|
||||||
@@ -351,7 +351,7 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
approved_items = [
|
approved_items = [
|
||||||
item
|
item
|
||||||
for item in (plan.items if plan else [])
|
for item in (plan.items if plan else [])
|
||||||
if item.approval_status == MealPlanItemStatus.APPROVED
|
if item.approval_status == MealPlanItemStatus.approved
|
||||||
]
|
]
|
||||||
|
|
||||||
members = (
|
members = (
|
||||||
@@ -482,7 +482,7 @@ def step_reminder(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
pending_item_ids = [
|
pending_item_ids = [
|
||||||
item.id
|
item.id
|
||||||
for item in plan.items
|
for item in plan.items
|
||||||
if item.approval_status == MealPlanItemStatus.PENDING
|
if item.approval_status == MealPlanItemStatus.pending
|
||||||
]
|
]
|
||||||
if not pending_item_ids:
|
if not pending_item_ids:
|
||||||
run.reminded_at = datetime.now(timezone.utc)
|
run.reminded_at = datetime.now(timezone.utc)
|
||||||
@@ -512,7 +512,7 @@ def step_reminder(run: "WeeklyRun", db: "Session") -> None:
|
|||||||
unvoted = [
|
unvoted = [
|
||||||
item
|
item
|
||||||
for item in plan.items
|
for item in plan.items
|
||||||
if item.approval_status == MealPlanItemStatus.PENDING
|
if item.approval_status == MealPlanItemStatus.pending
|
||||||
and item.id not in voted_ids
|
and item.id not in voted_ids
|
||||||
]
|
]
|
||||||
if not unvoted:
|
if not unvoted:
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ def generate_meal_plan(
|
|||||||
recipe_id=scored_recipe.recipe_id,
|
recipe_id=scored_recipe.recipe_id,
|
||||||
day_of_week=index + 1, # Mon=1, Tue=2, Wed=3 by default
|
day_of_week=index + 1, # Mon=1, Tue=2, Wed=3 by default
|
||||||
meal_type=MealType.DINNER,
|
meal_type=MealType.DINNER,
|
||||||
approval_status=MealPlanItemStatus.PENDING,
|
approval_status=MealPlanItemStatus.pending,
|
||||||
estimated_cost=scored_recipe.cost.total_cost,
|
estimated_cost=scored_recipe.cost.total_cost,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def pending_item(db, meal_plan):
|
|||||||
recipe_id=recipe.id,
|
recipe_id=recipe.id,
|
||||||
day_of_week=5,
|
day_of_week=5,
|
||||||
meal_type=MealType.DINNER,
|
meal_type=MealType.DINNER,
|
||||||
approval_status=MealPlanItemStatus.PENDING,
|
approval_status=MealPlanItemStatus.pending,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -467,7 +467,7 @@ def test_step_deadline_resolves_pending_to_approved(db, weekly_run_emailed, meal
|
|||||||
db.flush()
|
db.flush()
|
||||||
step_deadline(weekly_run_emailed, db)
|
step_deadline(weekly_run_emailed, db)
|
||||||
db.refresh(pending_item)
|
db.refresh(pending_item)
|
||||||
assert pending_item.approval_status == MealPlanItemStatus.APPROVED
|
assert pending_item.approval_status == MealPlanItemStatus.approved
|
||||||
assert weekly_run_emailed.deadline_passed_at is not None
|
assert weekly_run_emailed.deadline_passed_at is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -478,7 +478,7 @@ def test_step_deadline_resolves_pending_to_denied(db, weekly_run_emailed, meal_p
|
|||||||
db.flush()
|
db.flush()
|
||||||
step_deadline(weekly_run_emailed, db)
|
step_deadline(weekly_run_emailed, db)
|
||||||
db.refresh(pending_item)
|
db.refresh(pending_item)
|
||||||
assert pending_item.approval_status == MealPlanItemStatus.DENIED
|
assert pending_item.approval_status == MealPlanItemStatus.denied
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -520,7 +520,7 @@ def approved_item(db, meal_plan):
|
|||||||
recipe_id=recipe.id,
|
recipe_id=recipe.id,
|
||||||
day_of_week=6,
|
day_of_week=6,
|
||||||
meal_type=MealType.DINNER,
|
meal_type=MealType.DINNER,
|
||||||
approval_status=MealPlanItemStatus.APPROVED,
|
approval_status=MealPlanItemStatus.approved,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
Reference in New Issue
Block a user