Files
Meal-Planner/backend/app/api/pantry.py
T
admin 8ad4ef67a9 feat(ui): bulk pantry add + plan-the-week button (Sprint 6 F3+F4)
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.
2026-06-04 14:00:31 -07:00

160 lines
5.2 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import HomePantry, Ingredient, FamilyProfile
from app.schemas import (
HomePantryResponse,
HomePantryCreate,
HomePantryBulkCreate,
HomePantryBulkResult,
HomePantryBulkResultItem,
)
from app.security import require_session
from uuid import UUID
from typing import List
router = APIRouter()
@router.get("", response_model=List[HomePantryResponse])
def get_pantry_items(db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
items = db.query(HomePantry).filter(
HomePantry.family_profile_id == profile.id
).all()
return items
@router.post("", response_model=HomePantryResponse, dependencies=[Depends(require_session)])
def add_pantry_item(item: HomePantryCreate, 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(HomePantry).filter(
HomePantry.family_profile_id == profile.id,
HomePantry.ingredient_id == item.ingredient_id
).first()
if existing:
existing.quantity = item.quantity
existing.unit = item.unit
existing.expires_at = item.expires_at
db.commit()
db.refresh(existing)
return existing
db_item = HomePantry(
family_profile_id=profile.id,
ingredient_id=item.ingredient_id,
quantity=item.quantity,
unit=item.unit,
expires_at=item.expires_at
)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@router.post("/bulk", response_model=HomePantryBulkResult, dependencies=[Depends(require_session)])
def add_pantry_items_bulk(payload: HomePantryBulkCreate, db: Session = Depends(get_db)):
"""Add or upsert many pantry items in a single call.
Used by the Shopping List "Add checked to pantry" bulk action. Each
item follows the same semantics as POST /api/pantry (insert or
overwrite). Items missing an ingredient link or with an
unknown ingredient id are reported as "skipped" rather than
aborting the batch, so the user gets a precise count of what
actually went into their pantry.
"""
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
results: List[HomePantryBulkResultItem] = []
added = updated = skipped = 0
for item in payload.items:
# Validate the ingredient id before touching the DB.
ingredient = db.query(Ingredient).filter(Ingredient.id == item.ingredient_id).first()
if not ingredient:
results.append(HomePantryBulkResultItem(
ingredient_id=item.ingredient_id,
status="skipped",
reason="Unknown ingredient",
))
skipped += 1
continue
existing = db.query(HomePantry).filter(
HomePantry.family_profile_id == profile.id,
HomePantry.ingredient_id == item.ingredient_id,
).first()
if existing:
existing.quantity = item.quantity
existing.unit = item.unit
existing.expires_at = item.expires_at
db.flush()
results.append(HomePantryBulkResultItem(
ingredient_id=item.ingredient_id,
id=existing.id,
status="updated",
))
updated += 1
else:
db_item = HomePantry(
family_profile_id=profile.id,
ingredient_id=item.ingredient_id,
quantity=item.quantity,
unit=item.unit,
expires_at=item.expires_at,
)
db.add(db_item)
db.flush()
results.append(HomePantryBulkResultItem(
ingredient_id=item.ingredient_id,
id=db_item.id,
status="added",
))
added += 1
db.commit()
return HomePantryBulkResult(
added=added,
updated=updated,
skipped=skipped,
results=results,
)
@router.delete("/{item_id}", dependencies=[Depends(require_session)])
def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)):
item = db.query(HomePantry).filter(HomePantry.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Pantry item not found")
db.delete(item)
db.commit()
return {"message": "Pantry item removed"}
@router.put("/{item_id}", response_model=HomePantryResponse, dependencies=[Depends(require_session)])
def update_pantry_item(item_id: UUID, update: HomePantryCreate, db: Session = Depends(get_db)):
item = db.query(HomePantry).filter(HomePantry.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Pantry item not found")
item.ingredient_id = update.ingredient_id
item.quantity = update.quantity
item.unit = update.unit
item.expires_at = update.expires_at
db.commit()
db.refresh(item)
return item