feat(api): add /api/v1/meal-plans/ endpoints — CRUD, shopping list, prep session
refs kiwi#68 kiwi#71
This commit is contained in:
parent
b9dd1427de
commit
98087120ac
2 changed files with 379 additions and 0 deletions
263
app/api/endpoints/meal_plans.py
Normal file
263
app/api/endpoints/meal_plans.py
Normal 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)
|
||||||
116
tests/api/test_meal_plans.py
Normal file
116
tests/api/test_meal_plans.py
Normal 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
|
||||||
Loading…
Reference in a new issue