- Add GET /{plan_id}/prep-session endpoint so frontend can retrieve existing sessions without creating
- Fix list_plans response_model from list[dict] to list[PlanSummary] with proper _plan_summary() mapping
- Replace assert in store.update_prep_task with ValueError (assert is stripped under python -O)
- Add day_of_week 0-6 validation to upsert_slot endpoint
- Remove MagicMock sqlite artifact files left by pytest (already in .gitignore)
294 lines
10 KiB
Python
294 lines
10 KiB
Python
# app/api/endpoints/meal_plans.py
|
|
"""Meal plan CRUD, shopping list, and prep session endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from app.cloud_session import CloudUser, get_session
|
|
from app.db.session import get_store
|
|
from app.db.store import Store
|
|
from app.models.schemas.meal_plan import (
|
|
CreatePlanRequest,
|
|
GapItem,
|
|
PlanSummary,
|
|
PrepSessionSummary,
|
|
PrepTaskSummary,
|
|
ShoppingListResponse,
|
|
SlotSummary,
|
|
UpdatePrepTaskRequest,
|
|
UpsertSlotRequest,
|
|
VALID_MEAL_TYPES,
|
|
)
|
|
from app.services.meal_plan.affiliates import get_retailer_links
|
|
from app.services.meal_plan.prep_scheduler import build_prep_tasks
|
|
from app.services.meal_plan.shopping_list import compute_shopping_list
|
|
from app.tiers import can_use
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _slot_summary(row: dict) -> SlotSummary:
|
|
return SlotSummary(
|
|
id=row["id"],
|
|
plan_id=row["plan_id"],
|
|
day_of_week=row["day_of_week"],
|
|
meal_type=row["meal_type"],
|
|
recipe_id=row.get("recipe_id"),
|
|
recipe_title=row.get("recipe_title"),
|
|
servings=row["servings"],
|
|
custom_label=row.get("custom_label"),
|
|
)
|
|
|
|
|
|
def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary:
|
|
meal_types = plan.get("meal_types") or ["dinner"]
|
|
if isinstance(meal_types, str):
|
|
meal_types = json.loads(meal_types)
|
|
return PlanSummary(
|
|
id=plan["id"],
|
|
week_start=plan["week_start"],
|
|
meal_types=meal_types,
|
|
slots=[_slot_summary(s) for s in slots],
|
|
created_at=plan["created_at"],
|
|
)
|
|
|
|
|
|
def _prep_task_summary(row: dict) -> PrepTaskSummary:
|
|
return PrepTaskSummary(
|
|
id=row["id"],
|
|
recipe_id=row.get("recipe_id"),
|
|
task_label=row["task_label"],
|
|
duration_minutes=row.get("duration_minutes"),
|
|
sequence_order=row["sequence_order"],
|
|
equipment=row.get("equipment"),
|
|
is_parallel=bool(row.get("is_parallel", False)),
|
|
notes=row.get("notes"),
|
|
user_edited=bool(row.get("user_edited", False)),
|
|
)
|
|
|
|
|
|
# ── plan CRUD ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/", response_model=PlanSummary)
|
|
async def create_plan(
|
|
req: CreatePlanRequest,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> PlanSummary:
|
|
# Free tier is locked to dinner-only; paid+ may configure meal types
|
|
if can_use("meal_plan_config", session.tier):
|
|
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
|
else:
|
|
meal_types = ["dinner"]
|
|
|
|
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
|
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
|
return _plan_summary(plan, slots)
|
|
|
|
|
|
@router.get("/", response_model=list[PlanSummary])
|
|
async def list_plans(
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> list[PlanSummary]:
|
|
plans = await asyncio.to_thread(store.list_meal_plans)
|
|
result = []
|
|
for p in plans:
|
|
slots = await asyncio.to_thread(store.get_plan_slots, p["id"])
|
|
result.append(_plan_summary(p, slots))
|
|
return result
|
|
|
|
|
|
@router.get("/{plan_id}", response_model=PlanSummary)
|
|
async def get_plan(
|
|
plan_id: int,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> PlanSummary:
|
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
return _plan_summary(plan, slots)
|
|
|
|
|
|
# ── slots ─────────────────────────────────────────────────────────────────────
|
|
|
|
@router.put("/{plan_id}/slots/{day_of_week}/{meal_type}", response_model=SlotSummary)
|
|
async def upsert_slot(
|
|
plan_id: int,
|
|
day_of_week: int,
|
|
meal_type: str,
|
|
req: UpsertSlotRequest,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> SlotSummary:
|
|
if day_of_week < 0 or day_of_week > 6:
|
|
raise HTTPException(status_code=422, detail="day_of_week must be 0-6.")
|
|
if meal_type not in VALID_MEAL_TYPES:
|
|
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
|
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
row = await asyncio.to_thread(
|
|
store.upsert_slot,
|
|
plan_id, day_of_week, meal_type,
|
|
req.recipe_id, req.servings, req.custom_label,
|
|
)
|
|
return _slot_summary(row)
|
|
|
|
|
|
@router.delete("/{plan_id}/slots/{slot_id}", status_code=204)
|
|
async def delete_slot(
|
|
plan_id: int,
|
|
slot_id: int,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> None:
|
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
await asyncio.to_thread(store.delete_slot, slot_id)
|
|
|
|
|
|
# ── shopping list ─────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{plan_id}/shopping-list", response_model=ShoppingListResponse)
|
|
async def get_shopping_list(
|
|
plan_id: int,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> ShoppingListResponse:
|
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
|
|
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
|
|
inventory = await asyncio.to_thread(store.list_inventory)
|
|
|
|
gaps, covered = compute_shopping_list(recipes, inventory)
|
|
|
|
# Enrich gap items with retailer links
|
|
def _to_schema(item, enrich: bool) -> GapItem:
|
|
links = get_retailer_links(item.ingredient_name) if enrich else []
|
|
return GapItem(
|
|
ingredient_name=item.ingredient_name,
|
|
needed_raw=item.needed_raw,
|
|
have_quantity=item.have_quantity,
|
|
have_unit=item.have_unit,
|
|
covered=item.covered,
|
|
retailer_links=links,
|
|
)
|
|
|
|
gap_items = [_to_schema(g, enrich=True) for g in gaps]
|
|
covered_items = [_to_schema(c, enrich=False) for c in covered]
|
|
|
|
disclosure = (
|
|
"Some links may be affiliate links. Purchases through them support Kiwi development."
|
|
if gap_items else None
|
|
)
|
|
|
|
return ShoppingListResponse(
|
|
plan_id=plan_id,
|
|
gap_items=gap_items,
|
|
covered_items=covered_items,
|
|
disclosure=disclosure,
|
|
)
|
|
|
|
|
|
# ── prep session ──────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
|
async def get_prep_session(
|
|
plan_id: int,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> PrepSessionSummary:
|
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id)
|
|
if prep_session is None:
|
|
raise HTTPException(status_code=404, detail="No prep session for this plan.")
|
|
raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"])
|
|
return PrepSessionSummary(
|
|
id=prep_session["id"],
|
|
plan_id=plan_id,
|
|
scheduled_date=prep_session["scheduled_date"],
|
|
status=prep_session["status"],
|
|
tasks=[_prep_task_summary(t) for t in raw_tasks],
|
|
)
|
|
|
|
|
|
@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
|
async def create_prep_session(
|
|
plan_id: int,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> PrepSessionSummary:
|
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
|
|
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
|
|
prep_tasks = build_prep_tasks(slots=slots, recipes=recipes)
|
|
|
|
scheduled_date = date.today().isoformat()
|
|
prep_session = await asyncio.to_thread(
|
|
store.create_prep_session, plan_id, scheduled_date
|
|
)
|
|
session_id = prep_session["id"]
|
|
|
|
task_dicts = [
|
|
{
|
|
"recipe_id": t.recipe_id,
|
|
"slot_id": t.slot_id,
|
|
"task_label": t.task_label,
|
|
"duration_minutes": t.duration_minutes,
|
|
"sequence_order": t.sequence_order,
|
|
"equipment": t.equipment,
|
|
"is_parallel": t.is_parallel,
|
|
"notes": t.notes,
|
|
}
|
|
for t in prep_tasks
|
|
]
|
|
inserted = await asyncio.to_thread(store.bulk_insert_prep_tasks, session_id, task_dicts)
|
|
|
|
return PrepSessionSummary(
|
|
id=prep_session["id"],
|
|
plan_id=prep_session["plan_id"],
|
|
scheduled_date=prep_session["scheduled_date"],
|
|
status=prep_session["status"],
|
|
tasks=[_prep_task_summary(r) for r in inserted],
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/{plan_id}/prep-session/tasks/{task_id}",
|
|
response_model=PrepTaskSummary,
|
|
)
|
|
async def update_prep_task(
|
|
plan_id: int,
|
|
task_id: int,
|
|
req: UpdatePrepTaskRequest,
|
|
session: CloudUser = Depends(get_session),
|
|
store: Store = Depends(get_store),
|
|
) -> PrepTaskSummary:
|
|
updated = await asyncio.to_thread(
|
|
store.update_prep_task,
|
|
task_id,
|
|
duration_minutes=req.duration_minutes,
|
|
sequence_order=req.sequence_order,
|
|
notes=req.notes,
|
|
equipment=req.equipment,
|
|
)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Task not found.")
|
|
return _prep_task_summary(updated)
|