Public Access
F3 — Bulk 'add checked to pantry' on ShoppingList (the audit's F3 /
H7 finding). ShoppingList already had a 'checked' Set keyed on
ingredient_id and persisted to localStorage — that selection state
is the natural substrate for a bulk action.
Backend (POST /api/pantry/bulk):
- New endpoint that accepts {items: HomePantryCreate[]} and returns
HomePantryBulkResult with per-item status (added / updated /
skipped) and totals. Each item follows the same upsert semantics
as POST /api/pantry (insert or overwrite qty/unit/expires_at).
- Items with an unknown ingredient id are reported as 'skipped'
with reason='Unknown ingredient' rather than aborting the batch.
Per-item failure is the chosen model (partial-success) so the
user gets a precise count of what actually went in.
- New Pydantic schemas: HomePantryBulkCreate, HomePantryBulkResult,
HomePantryBulkResultItem.
Frontend:
- mealPlannerApi.pantry.addBulk(items) is the API binding.
- ShoppingList gets a new 'Add N to pantry' primary button (next
to the existing Reset button) that appears when checked.size > 0.
Click → POST /api/pantry/bulk → toast shows 'added X, updated Y,
skipped Z' counts. On success, only the items that actually
landed in the pantry are removed from the checked set; skipped
items stay checked so the user can see what failed.
- Disabled state with 'Adding…' label while the request is in
flight; button text shows the count dynamically (matches the
F4 design language: tell the user what they're about to do).
F4 — Plan the whole week (the audit's F4 / H7 finding).
Backend (POST /api/meals/{id}/fill-empty-slots):
- New endpoint that takes {meal_types: [str, ...]} and fills every
empty slot in the plan whose meal_type is in the request. Per-day
iteration (1-7) per meal_type, skipping already-occupied slots.
Recipe selection: prefer un-used, fall back to any (same as the
existing generate-item).
- Per-slot failure model: never aborts mid-batch. Returns
FillEmptySlotsResult { filled: [{day, meal_type, item}],
failed: [{day, meal_type, reason}] }. Invalid meal_types
(e.g. 'brunch') return immediately with a single FailedSlot
explaining why.
- Same approval_status=pending semantics as generate-item.
Frontend:
- mealPlannerApi.meals.fillEmptySlots(planId, mealTypes) is the
API binding.
- New 'Plan the week' button on the Dashboard header (next to the
week-nav control from Sprint 5). Primary color, Sparkles icon,
ChevronDown caret indicates a dropdown. Disabled + spinner
('Planning…') while the request runs.
- Dropdown has two options: 'Dinners only' (sends
meal_types=['dinner']) and 'All meals' (sends
meal_types=['breakfast','lunch','dinner']). Each option has a
one-line secondary label explaining the action.
- Toast on success: 'Planned N meal slots' (full) or 'Planned N
of M meal slots — X failed (e.g. <reason>)' (partial). The
query is then invalidated so the new slots show up.
Files: backend/app/api/meals.py, backend/app/api/pantry.py,
backend/app/schemas/__init__.py, frontend/src/api/index.ts,
frontend/src/pages/Dashboard.tsx, frontend/src/pages/ShoppingList.tsx.
Build: tsc 0 errors, vite 0 errors. Bundle +3.6KB (the new code
fits in the existing chunk).
Curl smoke on local dev DB confirms both new endpoints behave as
designed: /api/pantry/bulk returns proper skipped count for
unknown ingredients, /api/meals/{id}/fill-empty-slots returns
the partial-success result for the dinners-only call.
651 lines
23 KiB
Python
651 lines
23 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,
|
|
FillEmptySlotsRequest, FillEmptySlotsResult,
|
|
FilledSlot, FailedSlot,
|
|
)
|
|
from app.security import require_session
|
|
from app.services import approval as approval_service
|
|
from app.services.feedback_analyzer import FeedbackAnalyzer
|
|
from uuid import UUID
|
|
from typing import List, Optional
|
|
from datetime import datetime, timedelta
|
|
from datetime import date, 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(
|
|
week_start: Optional[date] = Query(
|
|
None,
|
|
description="ISO date of the week's Monday (YYYY-MM-DD). Omit for the most recent plan.",
|
|
),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
query = db.query(MealPlan).filter(MealPlan.family_profile_id == profile.id)
|
|
if week_start is not None:
|
|
query = query.filter(MealPlan.week_start_date == week_start)
|
|
meal_plan = query.first()
|
|
else:
|
|
meal_plan = query.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.delete("/items/{item_id}")
|
|
def delete_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|
"""Delete a meal plan item entirely."""
|
|
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
|
db.delete(item)
|
|
db.commit()
|
|
return {"message": "Meal deleted"}
|
|
|
|
|
|
@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.post("/{meal_plan_id}/fill-empty-slots", response_model=FillEmptySlotsResult)
|
|
def fill_empty_slots(
|
|
meal_plan_id: UUID,
|
|
payload: FillEmptySlotsRequest,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Fill every empty slot in the plan whose meal_type is in the
|
|
request's `meal_types`. Returns a per-slot report (filled vs
|
|
failed) so the UI can show "12 of 21 filled, 9 failed — recipe
|
|
library exhausted".
|
|
|
|
Failure model: per-slot. The endpoint never aborts mid-batch on
|
|
a single failure; it commits what succeeded and reports the
|
|
rest. This matches the user's chosen model (partial-success
|
|
with detailed report).
|
|
"""
|
|
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
|
if not plan:
|
|
raise HTTPException(status_code=404, detail="Meal plan not found")
|
|
|
|
# Normalise and validate the requested meal_types.
|
|
requested: list[str] = []
|
|
for mt in payload.meal_types:
|
|
try:
|
|
canonical = MealType[mt.upper()].value
|
|
except KeyError:
|
|
return FillEmptySlotsResult(
|
|
filled=[],
|
|
failed=[FailedSlot(day_of_week=0, meal_type=mt, reason=f"Unknown meal_type: {mt}")],
|
|
)
|
|
if canonical not in requested:
|
|
requested.append(canonical)
|
|
|
|
if not requested:
|
|
return FillEmptySlotsResult(filled=[], failed=[])
|
|
|
|
all_recipes = db.query(Recipe).all()
|
|
if not all_recipes:
|
|
# No recipes at all — every requested slot fails.
|
|
return FillEmptySlotsResult(
|
|
filled=[],
|
|
failed=[
|
|
FailedSlot(day_of_week=d, meal_type=mt, reason="No recipes available")
|
|
for d in range(1, 8)
|
|
for mt in requested
|
|
],
|
|
)
|
|
|
|
used_ids: set = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
|
|
|
filled: list[FilledSlot] = []
|
|
failed: list[FailedSlot] = []
|
|
|
|
for day in range(1, 8):
|
|
for mt in requested:
|
|
# Skip already-occupied slots.
|
|
existing = (
|
|
db.query(MealPlanItem)
|
|
.filter(
|
|
MealPlanItem.meal_plan_id == meal_plan_id,
|
|
MealPlanItem.day_of_week == day,
|
|
MealPlanItem.meal_type == mt,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
continue # not a failure, just nothing to do
|
|
|
|
# Pick a recipe; prefer un-used, fall back to any.
|
|
available = [r for r in all_recipes if r.id not in used_ids]
|
|
pool = available if available else all_recipes
|
|
recipe = random.choice(pool)
|
|
|
|
new_item = MealPlanItem(
|
|
meal_plan_id=meal_plan_id,
|
|
recipe_id=recipe.id,
|
|
day_of_week=day,
|
|
meal_type=MealType[mt.upper()],
|
|
approval_status=MealPlanItemStatus.pending,
|
|
)
|
|
db.add(new_item)
|
|
try:
|
|
db.flush()
|
|
used_ids.add(recipe.id)
|
|
filled.append(FilledSlot(
|
|
day_of_week=day,
|
|
meal_type=mt,
|
|
item=MealPlanItemResponse.model_validate(new_item),
|
|
))
|
|
except Exception as exc:
|
|
db.rollback()
|
|
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
|
failed.append(FilledSlot if False else FailedSlot(
|
|
day_of_week=day,
|
|
meal_type=mt,
|
|
reason=str(exc) or "Insert failed",
|
|
))
|
|
|
|
db.commit()
|
|
return FillEmptySlotsResult(filled=filled, failed=failed)
|
|
|
|
|
|
@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}
|
|
|
|
|
|
# ── Dashboard badge: newly discovered recipes ─────────────────────────
|
|
|
|
|
|
@router.get("/dashboard/discovered-count")
|
|
def discovered_count(db: Session = Depends(get_db)):
|
|
"""Return number of recipes discovered in the last 7 days for the current family."""
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
since = datetime.utcnow() - timedelta(days=7)
|
|
count = (
|
|
db.query(Recipe)
|
|
.filter(
|
|
Recipe.family_profile_id == profile.id,
|
|
Recipe.external_source.isnot(None),
|
|
Recipe.created_at >= since,
|
|
)
|
|
.count()
|
|
)
|
|
return {"discovered_count": count}
|
|
|
|
|
|
@router.get("/dashboard/discovery-insights")
|
|
def discovery_insights(db: Session = Depends(get_db)):
|
|
"""Return latest feedback analysis and top signals."""
|
|
profile = db.query(FamilyProfile).first()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
|
|
|
latest_run = (
|
|
db.query(WeeklyRun)
|
|
.filter(WeeklyRun.family_id == profile.id)
|
|
.order_by(WeeklyRun.week_start_date.desc())
|
|
.first()
|
|
)
|
|
if not latest_run or not latest_run.feedback_analysis:
|
|
return {"has_analysis": False}
|
|
|
|
analysis = latest_run.feedback_analysis
|
|
return {
|
|
"has_analysis": True,
|
|
"confidence": analysis.get("confidence", 0),
|
|
"positive_signals": analysis.get("positive_signals", []),
|
|
"negative_signals": analysis.get("negative_signals", []),
|
|
"top_rated_recipes": analysis.get("top_rated_recipes", []),
|
|
} |