feat(api): add /api/v1/meal-plans/ endpoints — CRUD, shopping list, prep session

refs kiwi#68 kiwi#71
This commit is contained in:
pyr0ball 2026-04-12 13:44:01 -07:00
parent b9dd1427de
commit 98087120ac
2 changed files with 379 additions and 0 deletions

View file

@ -0,0 +1,263 @@
# app/api/endpoints/meal_plans.py
"""Meal plan CRUD, shopping list, and prep session endpoints."""
from __future__ import annotations
import asyncio
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):
import json
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, 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[dict])
async def list_plans(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> list[dict]:
return await asyncio.to_thread(store.list_meal_plans)
@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:
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.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)

View file

@ -0,0 +1,116 @@
# tests/api/test_meal_plans.py
"""Integration tests for /api/v1/meal-plans/ endpoints."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.cloud_session import get_session
from app.db.session import get_store
from app.main import app
client = TestClient(app)
def _make_session(tier: str = "free") -> MagicMock:
m = MagicMock()
m.tier = tier
m.has_byok = False
return m
def _make_store() -> MagicMock:
m = MagicMock()
m.create_meal_plan.return_value = {
"id": 1, "week_start": "2026-04-14",
"meal_types": ["dinner"], "created_at": "2026-04-12T10:00:00",
}
m.list_meal_plans.return_value = []
m.get_meal_plan.return_value = None
m.get_plan_slots.return_value = []
m.upsert_slot.return_value = {
"id": 1, "plan_id": 1, "day_of_week": 0, "meal_type": "dinner",
"recipe_id": 42, "recipe_title": "Pasta", "servings": 2.0, "custom_label": None,
}
m.get_inventory.return_value = []
m.get_plan_recipes.return_value = []
m.get_prep_session_for_plan.return_value = None
m.create_prep_session.return_value = {
"id": 1, "plan_id": 1, "scheduled_date": "2026-04-13",
"status": "draft", "created_at": "2026-04-12T10:00:00",
}
m.get_prep_tasks.return_value = []
m.bulk_insert_prep_tasks.return_value = []
return m
@pytest.fixture()
def free_session():
session = _make_session("free")
store = _make_store()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_store] = lambda: store
yield store
app.dependency_overrides.clear()
@pytest.fixture()
def paid_session():
session = _make_session("paid")
store = _make_store()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_store] = lambda: store
yield store
app.dependency_overrides.clear()
def test_create_plan_free_tier_locks_to_dinner(free_session):
resp = client.post("/api/v1/meal-plans/", json={
"week_start": "2026-04-14", "meal_types": ["breakfast", "dinner"]
})
assert resp.status_code == 200
# Free tier forced to dinner-only regardless of request
free_session.create_meal_plan.assert_called_once_with("2026-04-14", ["dinner"])
def test_create_plan_paid_tier_respects_meal_types(paid_session):
resp = client.post("/api/v1/meal-plans/", json={
"week_start": "2026-04-14", "meal_types": ["breakfast", "lunch", "dinner"]
})
assert resp.status_code == 200
paid_session.create_meal_plan.assert_called_once_with(
"2026-04-14", ["breakfast", "lunch", "dinner"]
)
def test_list_plans_returns_200(free_session):
resp = client.get("/api/v1/meal-plans/")
assert resp.status_code == 200
assert resp.json() == []
def test_upsert_slot_returns_200(free_session):
free_session.get_meal_plan.return_value = {
"id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"],
"created_at": "2026-04-12T10:00:00",
}
resp = client.put(
"/api/v1/meal-plans/1/slots/0/dinner",
json={"recipe_id": 42, "servings": 2.0},
)
assert resp.status_code == 200
def test_get_shopping_list_returns_200(free_session):
free_session.get_meal_plan.return_value = {
"id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"],
"created_at": "2026-04-12T10:00:00",
}
resp = client.get("/api/v1/meal-plans/1/shopping-list")
assert resp.status_code == 200
body = resp.json()
assert "gap_items" in body
assert "covered_items" in body