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""" Already Voted

Hi {safe_voter}, you already voted on this meal

{safe_recipe}
{safe_day} · {safe_meal}
Your vote has already been recorded. Thank you!
""" return HTMLResponse(content=already_voted_html, status_code=200) html = f""" Approve meal

Hi {safe_voter}, please vote on this meal

{safe_recipe}
{safe_day} · {safe_meal}
""" 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}