kiwi/app/api/endpoints/shopping.py
pyr0ball 890216a1f0
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
fix: wire recipe corpus to cloud per-user DBs via SQLite ATTACH (#102)
Cloud mode: attach shared read-only corpus DB (RECIPE_DB_PATH env var)
as "corpus" schema so per-user SQLite DBs can access 3.19M recipes.
All corpus table references now use self._cp prefix ("corpus." in cloud,
"" in local). FTS5 pseudo-column kept unqualified per SQLite spec.
compose.cloud.yml: bind-mount /Library/Assets/kiwi/kiwi.db read-only.

Also fix batch of audit issues:
- #101: OCR approval used source="receipt_ocr" for inventory_items — use "receipt"
- #89/#100: Shopping confirm-purchase used source="shopping_list" — use "manual"
- #103: Frontend inventory filter sent ?status= but API expects ?item_status=
- #104: InventoryItemUpdate schema missing purchase_date field; store.py allowed set also missing it
- #105: Guest cookie Secure flag tied to CLOUD_MODE instead of X-Forwarded-Proto; broke HTTP direct-port access
2026-04-18 14:21:56 -07:00

224 lines
8.6 KiB
Python

"""Shopping list endpoints.
Free tier for all users (anonymous guests included — shopping list is the
primary affiliate revenue surface). Confirm-purchase action is also Free:
it moves a checked item into pantry inventory without a tier gate so the
flow works for anyone who signs up or browses without an account.
Routes:
GET /shopping — list items (with affiliate links)
POST /shopping — add item manually
PATCH /shopping/{id} — update (check/uncheck, rename, qty)
DELETE /shopping/{id} — remove single item
DELETE /shopping/checked — clear all checked items
DELETE /shopping/all — clear entire list
POST /shopping/from-recipe — bulk add gaps from a recipe
POST /shopping/{id}/confirm — confirm purchase → add to pantry inventory
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, status
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.shopping import (
BulkAddFromRecipeRequest,
ConfirmPurchaseRequest,
ShoppingItemCreate,
ShoppingItemResponse,
ShoppingItemUpdate,
)
from app.services.recipe.grocery_links import GroceryLinkBuilder
log = logging.getLogger(__name__)
router = APIRouter()
def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
"""Attach live affiliate links to a raw store row."""
links = builder.build_links(item["name"])
return ShoppingItemResponse(
**{**item, "checked": bool(item.get("checked", 0))},
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links],
)
def _in_thread(db_path, fn):
store = Store(db_path)
try:
return fn(store)
finally:
store.close()
# ── List ──────────────────────────────────────────────────────────────────────
@router.get("", response_model=list[ShoppingItemResponse])
async def list_shopping_items(
include_checked: bool = True,
session: CloudUser = Depends(get_session),
):
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
items = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
)
return [_enrich(i, builder) for i in items]
# ── Add manually ──────────────────────────────────────────────────────────────
@router.post("", response_model=ShoppingItemResponse, status_code=status.HTTP_201_CREATED)
async def add_shopping_item(
body: ShoppingItemCreate,
session: CloudUser = Depends(get_session),
):
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
item = await asyncio.to_thread(
_in_thread,
session.db,
lambda s: s.add_shopping_item(
name=body.name,
quantity=body.quantity,
unit=body.unit,
category=body.category,
notes=body.notes,
source=body.source,
recipe_id=body.recipe_id,
sort_order=body.sort_order,
),
)
return _enrich(item, builder)
# ── Bulk add from recipe ───────────────────────────────────────────────────────
@router.post("/from-recipe", response_model=list[ShoppingItemResponse], status_code=status.HTTP_201_CREATED)
async def add_from_recipe(
body: BulkAddFromRecipeRequest,
session: CloudUser = Depends(get_session),
):
"""Add missing ingredients from a recipe to the shopping list.
Runs pantry gap analysis and adds only the items the user doesn't have
(unless include_covered=True). Skips duplicates already on the list.
"""
from app.services.meal_plan.shopping_list import compute_shopping_list
def _run(store: Store):
recipe = store.get_recipe(body.recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
inventory = store.list_inventory()
gaps, covered = compute_shopping_list([recipe], inventory)
targets = (gaps + covered) if body.include_covered else gaps
# Avoid duplicates already on the list
existing = {i["name"].lower() for i in store.list_shopping_items()}
added = []
for gap in targets:
if gap.ingredient_name.lower() in existing:
continue
item = store.add_shopping_item(
name=gap.ingredient_name,
quantity=None,
unit=gap.have_unit,
source="recipe",
recipe_id=body.recipe_id,
)
added.append(item)
return added
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
items = await asyncio.to_thread(_in_thread, session.db, _run)
return [_enrich(i, builder) for i in items]
# ── Update ────────────────────────────────────────────────────────────────────
@router.patch("/{item_id}", response_model=ShoppingItemResponse)
async def update_shopping_item(
item_id: int,
body: ShoppingItemUpdate,
session: CloudUser = Depends(get_session),
):
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
item = await asyncio.to_thread(
_in_thread,
session.db,
lambda s: s.update_shopping_item(item_id, **body.model_dump(exclude_none=True)),
)
if not item:
raise HTTPException(status_code=404, detail="Shopping item not found")
return _enrich(item, builder)
# ── Confirm purchase → pantry ─────────────────────────────────────────────────
@router.post("/{item_id}/confirm", status_code=status.HTTP_201_CREATED)
async def confirm_purchase(
item_id: int,
body: ConfirmPurchaseRequest,
session: CloudUser = Depends(get_session),
):
"""Confirm a checked item was purchased and add it to pantry inventory.
Human approval step: the user explicitly confirms what they actually bought
before it lands in their pantry. Returns the new inventory item.
"""
def _run(store: Store):
shopping_item = store.get_shopping_item(item_id)
if not shopping_item:
raise HTTPException(status_code=404, detail="Shopping item not found")
qty = body.quantity if body.quantity is not None else (shopping_item.get("quantity") or 1.0)
unit = body.unit or shopping_item.get("unit") or "count"
category = shopping_item.get("category")
product = store.get_or_create_product(
name=shopping_item["name"],
category=category,
)
inv_item = store.add_inventory_item(
product_id=product["id"],
location=body.location,
quantity=qty,
unit=unit,
source="manual",
)
# Mark the shopping item checked and leave it for the user to clear
store.update_shopping_item(item_id, checked=True)
return inv_item
return await asyncio.to_thread(_in_thread, session.db, _run)
# ── Delete ────────────────────────────────────────────────────────────────────
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_shopping_item(
item_id: int,
session: CloudUser = Depends(get_session),
):
deleted = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.delete_shopping_item(item_id)
)
if not deleted:
raise HTTPException(status_code=404, detail="Shopping item not found")
@router.delete("/checked", status_code=status.HTTP_204_NO_CONTENT)
async def clear_checked(session: CloudUser = Depends(get_session)):
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.clear_checked_shopping_items()
)
@router.delete("/all", status_code=status.HTTP_204_NO_CONTENT)
async def clear_all(session: CloudUser = Depends(get_session)):
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.clear_all_shopping_items()
)