Files
Meal-Planner/backend/app/api/meals.py
T
admin ddcdb962ca fix: atomic SQL swap for meal move endpoint
Replace two-step ORM update with single UPDATE ... CASE statement.
Eliminates IntegrityError from SQLAlchemy flush order violating the
unique constraint (meal_plan_id, day_of_week, meal_type).
2026-05-14 15:56:47 -07:00

390 lines
13 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse
from html import escape as _html_escape
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import (
MealPlan, MealPlanItem, MealPlanVote, Recipe,
FamilyProfile, FamilyMember, ApprovalToken,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus
)
from app.schemas import (
MealPlanResponse, MealPlanCreate,
MealPlanItemResponse, VoteRequest, VoteResponse
)
from app.security import require_session
from app.services import approval as approval_service
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta
router = APIRouter()
@router.get("", response_model=Optional[MealPlanResponse])
def get_planned_meals(db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
meal_plan = db.query(MealPlan).filter(
MealPlan.family_profile_id == profile.id
).order_by(MealPlan.week_start_date.desc()).first()
if not meal_plan:
return None
return meal_plan
@router.post("", response_model=MealPlanResponse, dependencies=[Depends(require_session)])
def create_meal_plan(meal_plan_data: MealPlanCreate, db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
existing = db.query(MealPlan).filter(
MealPlan.family_profile_id == profile.id,
MealPlan.week_start_date == meal_plan_data.week_start_date
).first()
if existing:
raise HTTPException(status_code=400, detail="Meal plan for this week already exists")
db_meal_plan = MealPlan(
family_profile_id=profile.id,
week_start_date=meal_plan_data.week_start_date,
status=MealPlanStatus[meal_plan_data.status.value.upper()],
approval_deadline=meal_plan_data.approval_deadline,
notes=meal_plan_data.notes
)
db.add(db_meal_plan)
db.flush()
for item_data in meal_plan_data.items:
db_item = MealPlanItem(
meal_plan_id=db_meal_plan.id,
recipe_id=item_data.recipe_id,
day_of_week=item_data.day_of_week,
meal_type=MealType[item_data.meal_type.value.upper()],
estimated_cost=item_data.estimated_cost
)
db.add(db_item)
db.commit()
db.refresh(db_meal_plan)
return db_meal_plan
@router.get("/{meal_plan_id}", response_model=MealPlanResponse)
def get_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
if not meal_plan:
raise HTTPException(status_code=404, detail="Meal plan not found")
return meal_plan
@router.post("/{meal_plan_id}/lock", dependencies=[Depends(require_session)])
def lock_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)):
meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
if not meal_plan:
raise HTTPException(status_code=404, detail="Meal plan not found")
meal_plan.status = MealPlanStatus.LOCKED
db.commit()
return {"message": "Meal plan locked", "status": meal_plan.status.value}
_DAY_NAMES = {
1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday",
5: "Friday", 6: "Saturday", 7: "Sunday",
}
class VoteSubmission(BaseModel):
"""Body for POST /vote/{item_id}?token=...
Spec body: {"vote": "approve" | "deny"}.
"""
vote: str = Field(..., pattern="^(approve|deny)$")
@router.get("/vote/{item_id}", response_class=HTMLResponse)
def get_vote_page(
item_id: UUID,
token: str = Query(..., description="Per-voter signed token"),
db: Session = Depends(get_db),
):
"""Render the per-voter approval confirmation page.
Verifies the signed token (no consume) and returns minimal accessible
HTML with Approve / Deny buttons that POST to the same URL.
"""
payload = approval_service.verify_token(token)
if str(payload.get("item")) != str(item_id):
raise HTTPException(status_code=400, detail="Token not valid for this meal")
voter = (
db.query(FamilyMember)
.filter(FamilyMember.id == UUID(str(payload["voter"])))
.first()
)
if not voter:
raise HTTPException(status_code=404, detail="Voter not found")
item = (
db.query(MealPlanItem)
.options(joinedload(MealPlanItem.recipe))
.filter(MealPlanItem.id == item_id)
.first()
)
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
recipe_name = item.recipe.name if item.recipe else "Unnamed meal"
day_name = _DAY_NAMES.get(int(item.day_of_week), str(item.day_of_week))
meal_type = item.meal_type.value if item.meal_type else ""
# Token is included only inside the form action (href), never in the
# visible body. POST is performed by JS so we can submit JSON without
# leaving the page; non-JS users still get a usable form fallback.
safe_voter = _html_escape(voter.name)
safe_recipe = _html_escape(recipe_name)
safe_day = _html_escape(day_name)
safe_meal = _html_escape(meal_type)
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} &middot; {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 lang="en">
<head>
<meta charset="utf-8">
<title>Approve meal</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; }}
button {{ font-size: 1rem; padding: .6rem 1.2rem; margin-right: .5rem;
border: 2px solid #111; border-radius: 4px; cursor: pointer; }}
.approve {{ background: #0a6b2b; color: #fff; }}
.deny {{ background: #b00020; color: #fff; }}
#status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
<body>
<h1>Hi {safe_voter}, please vote on this meal</h1>
<div class="meal">
<div><strong>{safe_recipe}</strong></div>
<div>{safe_day} &middot; {safe_meal}</div>
</div>
<form id="voteForm" method="post" action="{action_url}">
<button type="submit" name="vote" value="approve" class="approve" aria-label="Approve this meal">Approve</button>
<button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal">Deny</button>
</form>
<div id="status" role="status" aria-live="polite"></div>
<script>
document.getElementById('voteForm').addEventListener('submit', async function(e) {{
e.preventDefault();
var btn = e.submitter || document.activeElement;
var vote = btn && btn.value ? btn.value : 'approve';
var resp = await fetch(this.action, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ vote: vote }})
}});
var data = {{}};
try {{ data = await resp.json(); }} catch (_) {{}}
var s = document.getElementById('status');
if (resp.ok) {{
s.textContent = 'Recorded: ' + (data.item_status || vote);
}} else {{
s.textContent = 'Error: ' + (data.detail || resp.status);
}}
}});
</script>
</body>
</html>
"""
return HTMLResponse(content=html, status_code=200)
@router.post("/vote/{item_id}")
def submit_vote(
item_id: UUID,
submission: VoteSubmission,
token: str = Query(..., description="Per-voter signed token"),
db: Session = Depends(get_db),
):
"""Record a per-voter vote and apply the approval rule.
- Single-use enforcement lives in `approval_service.consume_token`.
- Approval rule: any deny -> item.denied; all-approve -> item.approved;
otherwise pending (waiting on remaining voters).
"""
voter = approval_service.consume_token(db, token, item_id)
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
vote_bool = submission.vote == "approve"
db.add(MealPlanVote(
meal_plan_item_id=item_id,
family_member_id=voter.id,
vote=vote_bool,
))
db.flush()
# Approval rule: count electorate (all family members on this profile)
# vs votes recorded so far.
profile_id = item.meal_plan.family_profile_id
electorate_ids = {
m.id for m in db.query(FamilyMember)
.filter(FamilyMember.family_profile_id == profile_id)
.all()
}
votes = db.query(MealPlanVote).filter(
MealPlanVote.meal_plan_item_id == item_id,
).all()
if any(v.vote is False for v in votes):
item.approval_status = MealPlanItemStatus.denied
elif electorate_ids and {v.family_member_id for v in votes} >= electorate_ids:
item.approval_status = MealPlanItemStatus.approved
else:
item.approval_status = MealPlanItemStatus.pending
db.commit()
return {"status": "recorded", "item_status": item.approval_status.value}
@router.get("/items/{item_id}", response_model=MealPlanItemResponse)
def get_meal_item(item_id: UUID, db: Session = Depends(get_db)):
item = db.query(MealPlanItem).options(joinedload(MealPlanItem.recipe)).filter(
MealPlanItem.id == item_id
).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
return item
@router.post("/items/{item_id}/swap", dependencies=[Depends(require_session)])
def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get_db)):
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
new_recipe = db.query(Recipe).filter(Recipe.id == new_recipe_id).first()
if not new_recipe:
raise HTTPException(status_code=404, detail="New recipe not found")
item.recipe_id = new_recipe_id
item.approval_status = MealPlanItemStatus.pending
item.denial_reason = None
item.denial_details = None
db.commit()
return {"message": "Meal swapped", "item": item}
@router.put("/items/{item_id}/move")
def move_meal_item(
item_id: UUID,
new_day_of_week: int,
new_meal_type: str,
db: Session = Depends(get_db),
):
"""Move a meal to a new day/type slot.
If the target slot is occupied by another item in the same meal plan,
swap the two items.
"""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
plan_id = item.meal_plan_id
conflict = (
db.query(MealPlanItem)
.filter(
MealPlanItem.meal_plan_id == plan_id,
MealPlanItem.day_of_week == new_day_of_week,
MealPlanItem.meal_type == new_meal_type,
MealPlanItem.id != item_id,
)
.first()
)
if conflict:
# Atomic swap via single SQL CASE statement — no intermediate
# unique-constraint violations.
db.execute(
text(
"""
UPDATE meal_plan_item
SET day_of_week = CASE
WHEN id = :item_id THEN :new_day
WHEN id = :conflict_id THEN :old_day
END,
meal_type = CASE
WHEN id = :item_id THEN :new_type
WHEN id = :conflict_id THEN :old_type
END
WHERE id IN (:item_id, :conflict_id)
"""
),
{
"item_id": str(item_id),
"conflict_id": str(conflict.id),
"new_day": new_day_of_week,
"new_type": new_meal_type,
"old_day": item.day_of_week,
"old_type": item.meal_type,
},
)
db.commit()
db.refresh(item)
return {"message": "Meal swapped", "item": item}
item.day_of_week = new_day_of_week
item.meal_type = new_meal_type
db.commit()
db.refresh(item)
return {"message": "Meal moved", "item": item}