Public Access
Recipe JSONB stores ingredient_id but not name. GET /api/meals/items/{id}
now queries the Ingredient table and injects names into the response so
the frontend displays "3 cups onion" instead of just "3 cups".
476 lines
16 KiB
Python
476 lines
16 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, Ingredient,
|
|
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
|
|
from uuid import UUID
|
|
from typing import List, Optional
|
|
import random
|
|
|
|
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} · {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} · {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")
|
|
|
|
# Enrich ingredient names from the Ingredient table
|
|
if item.recipe and item.recipe.ingredients:
|
|
ing_ids = [ing.get("ingredient_id") for ing in item.recipe.ingredients if ing.get("ingredient_id")]
|
|
if ing_ids:
|
|
ingredients = db.query(Ingredient).filter(Ingredient.id.in_(ing_ids)).all()
|
|
name_map = {str(i.id): i.name for i in ingredients}
|
|
for ing in item.recipe.ingredients:
|
|
ing_id = str(ing.get("ingredient_id", ""))
|
|
if ing_id in name_map and not ing.get("name"):
|
|
ing["name"] = name_map[ing_id]
|
|
|
|
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.post("/items/{item_id}/approve")
|
|
def approve_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|
"""Directly approve a meal plan item from the dashboard."""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
item.approval_status = MealPlanItemStatus.approved
|
|
db.commit()
|
|
return {"message": "Meal approved", "item": item}
|
|
|
|
|
|
@router.post("/items/{item_id}/deny")
|
|
def deny_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|
"""Directly deny a meal plan item from the dashboard."""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
item.approval_status = MealPlanItemStatus.denied
|
|
db.commit()
|
|
return {"message": "Meal denied", "item": item}
|
|
|
|
|
|
@router.post("/{meal_plan_id}/generate-item")
|
|
def generate_single_item(
|
|
meal_plan_id: UUID,
|
|
day_of_week: int = Query(..., ge=1, le=7),
|
|
meal_type: str = Query(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Generate a single meal for an empty slot."""
|
|
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=404, detail="Meal plan not found")
|
|
|
|
# Verify slot is empty
|
|
existing = (
|
|
db.query(MealPlanItem)
|
|
.filter(
|
|
MealPlanItem.meal_plan_id == meal_plan_id,
|
|
MealPlanItem.day_of_week == day_of_week,
|
|
MealPlanItem.meal_type == meal_type,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Slot already occupied")
|
|
|
|
# Get all recipes, score them, pick the best not already in this plan
|
|
all_recipes = db.query(Recipe).all()
|
|
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
|
|
|
# Simple heuristic: pick a random un-used recipe (we can improve scoring later)
|
|
available = [r for r in all_recipes if r.id not in used_ids]
|
|
if not available:
|
|
available = all_recipes # reuse if all recipes are in play
|
|
|
|
recipe = random.choice(available)
|
|
|
|
new_item = MealPlanItem(
|
|
meal_plan_id=meal_plan_id,
|
|
recipe_id=recipe.id,
|
|
day_of_week=day_of_week,
|
|
meal_type=MealType[meal_type.upper()],
|
|
approval_status=MealPlanItemStatus.pending,
|
|
)
|
|
db.add(new_item)
|
|
db.commit()
|
|
db.refresh(new_item)
|
|
return {"message": "Meal generated", "item": new_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} |