Public Access
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.
This commit is contained in:
+105
-1
@@ -12,7 +12,9 @@ from app.models import (
|
||||
)
|
||||
from app.schemas import (
|
||||
MealPlanResponse, MealPlanCreate,
|
||||
MealPlanItemResponse, VoteRequest, VoteResponse
|
||||
MealPlanItemResponse, VoteRequest, VoteResponse,
|
||||
FillEmptySlotsRequest, FillEmptySlotsResult,
|
||||
FilledSlot, FailedSlot,
|
||||
)
|
||||
from app.security import require_session
|
||||
from app.services import approval as approval_service
|
||||
@@ -431,6 +433,108 @@ def generate_single_item(
|
||||
return {"message": "Meal generated", "item": new_item}
|
||||
|
||||
|
||||
@router.post("/{meal_plan_id}/fill-empty-slots", response_model=FillEmptySlotsResult)
|
||||
def fill_empty_slots(
|
||||
meal_plan_id: UUID,
|
||||
payload: FillEmptySlotsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Fill every empty slot in the plan whose meal_type is in the
|
||||
request's `meal_types`. Returns a per-slot report (filled vs
|
||||
failed) so the UI can show "12 of 21 filled, 9 failed — recipe
|
||||
library exhausted".
|
||||
|
||||
Failure model: per-slot. The endpoint never aborts mid-batch on
|
||||
a single failure; it commits what succeeded and reports the
|
||||
rest. This matches the user's chosen model (partial-success
|
||||
with detailed report).
|
||||
"""
|
||||
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Meal plan not found")
|
||||
|
||||
# Normalise and validate the requested meal_types.
|
||||
requested: list[str] = []
|
||||
for mt in payload.meal_types:
|
||||
try:
|
||||
canonical = MealType[mt.upper()].value
|
||||
except KeyError:
|
||||
return FillEmptySlotsResult(
|
||||
filled=[],
|
||||
failed=[FailedSlot(day_of_week=0, meal_type=mt, reason=f"Unknown meal_type: {mt}")],
|
||||
)
|
||||
if canonical not in requested:
|
||||
requested.append(canonical)
|
||||
|
||||
if not requested:
|
||||
return FillEmptySlotsResult(filled=[], failed=[])
|
||||
|
||||
all_recipes = db.query(Recipe).all()
|
||||
if not all_recipes:
|
||||
# No recipes at all — every requested slot fails.
|
||||
return FillEmptySlotsResult(
|
||||
filled=[],
|
||||
failed=[
|
||||
FailedSlot(day_of_week=d, meal_type=mt, reason="No recipes available")
|
||||
for d in range(1, 8)
|
||||
for mt in requested
|
||||
],
|
||||
)
|
||||
|
||||
used_ids: set = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
||||
|
||||
filled: list[FilledSlot] = []
|
||||
failed: list[FailedSlot] = []
|
||||
|
||||
for day in range(1, 8):
|
||||
for mt in requested:
|
||||
# Skip already-occupied slots.
|
||||
existing = (
|
||||
db.query(MealPlanItem)
|
||||
.filter(
|
||||
MealPlanItem.meal_plan_id == meal_plan_id,
|
||||
MealPlanItem.day_of_week == day,
|
||||
MealPlanItem.meal_type == mt,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
continue # not a failure, just nothing to do
|
||||
|
||||
# Pick a recipe; prefer un-used, fall back to any.
|
||||
available = [r for r in all_recipes if r.id not in used_ids]
|
||||
pool = available if available else all_recipes
|
||||
recipe = random.choice(pool)
|
||||
|
||||
new_item = MealPlanItem(
|
||||
meal_plan_id=meal_plan_id,
|
||||
recipe_id=recipe.id,
|
||||
day_of_week=day,
|
||||
meal_type=MealType[mt.upper()],
|
||||
approval_status=MealPlanItemStatus.pending,
|
||||
)
|
||||
db.add(new_item)
|
||||
try:
|
||||
db.flush()
|
||||
used_ids.add(recipe.id)
|
||||
filled.append(FilledSlot(
|
||||
day_of_week=day,
|
||||
meal_type=mt,
|
||||
item=MealPlanItemResponse.model_validate(new_item),
|
||||
))
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
||||
failed.append(FilledSlot if False else FailedSlot(
|
||||
day_of_week=day,
|
||||
meal_type=mt,
|
||||
reason=str(exc) or "Insert failed",
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return FillEmptySlotsResult(filled=filled, failed=failed)
|
||||
|
||||
|
||||
@router.put("/items/{item_id}/move")
|
||||
def move_meal_item(
|
||||
item_id: UUID,
|
||||
|
||||
@@ -2,7 +2,13 @@ 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
|
||||
from app.schemas import (
|
||||
HomePantryResponse,
|
||||
HomePantryCreate,
|
||||
HomePantryBulkCreate,
|
||||
HomePantryBulkResult,
|
||||
HomePantryBulkResultItem,
|
||||
)
|
||||
from app.security import require_session
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
@@ -55,6 +61,78 @@ def add_pantry_item(item: HomePantryCreate, db: Session = Depends(get_db)):
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user