Files
Meal-Planner/backend/app/schemas/__init__.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

385 lines
9.8 KiB
Python

from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
# Re-export from submodules so forward references resolve
from .recipe import (
RecipeBase,
RecipeCreate,
RecipeIngredientRef,
RecipeRead,
RecipeUpdate,
ResolveIngredientCandidate,
ResolveIngredientRequest,
ResolveIngredientResponse,
)
# These may be referenced by other models; re-export as aliases if needed.
RecipeResponse = RecipeRead
class FamilyMemberRole(str, Enum):
adult = "adult"
child = "child"
class MealType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
class MealPlanStatus(str, Enum):
draft = "draft"
pending_approval = "pending_approval"
approved = "approved"
locked = "locked"
class MealPlanItemStatus(str, Enum):
pending = "pending"
approved = "approved"
denied = "denied"
swapped = "swapped"
class DenialReason(str, Enum):
too_expensive = "too_expensive"
boring = "boring"
disliked_ingredient = "disliked_ingredient"
cultural = "cultural"
other = "other"
class NeverSuggestReason(str, Enum):
allergy = "allergy"
dislike = "dislike"
tried_too_much = "tried_too_much"
other = "other"
class IngredientBase(BaseModel):
name: str
name_lower: str
plural_name: Optional[str] = None
aisle: Optional[str] = None
typical_price: Optional[float] = None
unit: Optional[str] = None
season_months: Optional[List[int]] = None
class IngredientResponse(IngredientBase):
id: UUID
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class IngredientCreate(IngredientBase):
pass
class FamilyMemberBase(BaseModel):
name: str
email: Optional[str] = None
role: FamilyMemberRole
likes_mushrooms: bool = False
class FamilyMemberResponse(FamilyMemberBase):
id: UUID
family_profile_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class FamilyMemberCreate(FamilyMemberBase):
pass
class FamilyProfileBase(BaseModel):
name: str
household_size: int
adult_count: int
child_count: int
dietary_notes: Optional[str] = None
budget_per_meal: float = 50.00
class FamilyProfileResponse(BaseModel):
id: UUID
name: str
household_size: int
adult_count: int
child_count: int
dietary_notes: Optional[str] = None
budget_per_meal: float = 50.00
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
members: List[FamilyMemberResponse] = []
planner_config: Optional[dict] = None
class Config:
from_attributes = True
class PlannerConfigOverride(BaseModel):
recency_weeks: Optional[int] = Field(default=None, ge=0)
calorie_tolerance_pct: Optional[int] = Field(default=None, ge=0, le=100)
max_total_minutes: Optional[int] = Field(default=None, ge=0)
max_meal_cost: Optional[float] = Field(default=None, ge=0)
w_savings: Optional[float] = Field(default=None, ge=0, le=1)
w_coverage: Optional[float] = Field(default=None, ge=0, le=1)
w_pantry: Optional[float] = Field(default=None, ge=0, le=1)
w_time: Optional[float] = Field(default=None, ge=0, le=1)
w_recency: Optional[float] = Field(default=None, ge=0, le=1)
time_ideal_minutes: Optional[int] = Field(default=None, ge=0)
time_full_minutes: Optional[int] = Field(default=None, ge=0)
recency_full_weeks: Optional[int] = Field(default=None, ge=0)
top_k: Optional[int] = Field(default=None, ge=1)
set_size: Optional[int] = Field(default=None, ge=1)
p_protein: Optional[float] = Field(default=None, ge=0)
p_cuisine: Optional[float] = Field(default=None, ge=0)
class PlannerConfigResponse(BaseModel):
recency_weeks: int
calorie_tolerance_pct: int
max_total_minutes: int
max_meal_cost: float
w_savings: float
w_coverage: float
w_pantry: float
w_time: float
w_recency: float
time_ideal_minutes: int
time_full_minutes: int
recency_full_weeks: int
top_k: int
set_size: int
p_protein: float
p_cuisine: float
source: str = "default"
class PlannerConfigUpdateRequest(BaseModel):
planner_config: PlannerConfigOverride
class FamilyProfileCreate(FamilyProfileBase):
pass
class FamilyProfileUpdate(BaseModel):
name: Optional[str] = None
household_size: Optional[int] = None
adult_count: Optional[int] = None
child_count: Optional[int] = None
dietary_notes: Optional[str] = None
budget_per_meal: Optional[float] = None
planner_config: Optional[dict] = None
class RecipeCreate(RecipeBase):
pass
class MealPlanItemBase(BaseModel):
recipe_id: UUID
day_of_week: int = Field(..., ge=1, le=7)
meal_type: MealType
estimated_cost: Optional[float] = None
class MealPlanItemResponse(MealPlanItemBase):
id: UUID
meal_plan_id: UUID
approval_status: MealPlanItemStatus = MealPlanItemStatus.pending
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None
components: Optional[Dict[str, float]] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
recipe: Optional[RecipeResponse] = None
class Config:
from_attributes = True
class MealPlanItemCreate(MealPlanItemBase):
pass
class MealPlanBase(BaseModel):
week_start_date: date
status: MealPlanStatus = MealPlanStatus.draft
approval_deadline: Optional[datetime] = None
notes: Optional[str] = None
class MealPlanResponse(MealPlanBase):
id: UUID
family_profile_id: UUID
total_estimated_cost: Optional[float] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
items: List[MealPlanItemResponse] = []
class Config:
from_attributes = True
class MealPlanCreate(MealPlanBase):
items: List[MealPlanItemCreate] = []
class VoteRequest(BaseModel):
vote: bool
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
class VoteResponse(BaseModel):
id: UUID
meal_plan_item_id: UUID
family_member_id: UUID
vote: bool
voted_at: Optional[datetime] = None
class Config:
from_attributes = True
class HomePantryBase(BaseModel):
ingredient_id: UUID
quantity: Optional[float] = None
unit: Optional[str] = None
expires_at: Optional[date] = None
class HomePantryResponse(HomePantryBase):
id: UUID
family_profile_id: UUID
added_at: Optional[datetime] = None
created_at: Optional[datetime] = None
ingredient: Optional[IngredientResponse] = None
class Config:
from_attributes = True
class HomePantryCreate(HomePantryBase):
pass
class HomePantryBulkCreate(BaseModel):
"""Request body for POST /api/pantry/bulk. Accepts a list of items to
add in one call; each item follows the same upsert semantics as
HomePantryCreate (insert or overwrite qty/unit/expires)."""
items: List[HomePantryCreate]
class HomePantryBulkResultItem(BaseModel):
ingredient_id: UUID
status: str # "added" | "updated" | "skipped"
id: Optional[UUID] = None
reason: Optional[str] = None
class HomePantryBulkResult(BaseModel):
"""Response body for POST /api/pantry/bulk. Reports per-item
outcomes so the UI can show a precise toast ("Added 8 items, 1
skipped — no ingredient link"). Total counts are derived for
convenience."""
added: int
updated: int
skipped: int
results: List[HomePantryBulkResultItem]
class FillEmptySlotsRequest(BaseModel):
"""Request body for POST /api/meals/{id}/fill-empty-slots. The
caller selects which meal types to fill (dinner only, or all
three). Days 1-7 are filled automatically; the backend iterates
in day order then meal_type order."""
meal_types: List[str] = Field(
default_factory=lambda: ["breakfast", "lunch", "dinner"],
description="Subset of {breakfast, lunch, dinner} to fill.",
)
class FilledSlot(BaseModel):
day_of_week: int
meal_type: str
item: MealPlanItemResponse
class FailedSlot(BaseModel):
day_of_week: int
meal_type: str
reason: str
class FillEmptySlotsResult(BaseModel):
"""Response body for POST /api/meals/{id}/fill-empty-slots.
Reports each slot as either 'filled' (with the new MealPlanItem)
or 'failed' (with a human-readable reason). Partial success is
the model: the caller decides whether to retry the failed
slots."""
filled: List[FilledSlot]
failed: List[FailedSlot]
class FeedbackBase(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5)
never_suggest: bool = False
denial_reason: Optional[DenialReason] = None
feedback_text: Optional[str] = None
class FeedbackResponse(FeedbackBase):
id: UUID
family_profile_id: UUID
family_member_id: Optional[UUID] = None
meal_plan_item_id: UUID
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class FeedbackCreate(FeedbackBase):
meal_plan_item_id: UUID
class ShoppingListItem(BaseModel):
ingredient_id: Optional[UUID] = None
name: str
quantity: Optional[float] = None
unit: Optional[str] = None
aisle: Optional[str] = None
estimated_price: Optional[float] = None
is_on_sale: bool = False
sale_price: Optional[float] = None
in_season: bool = False
in_pantry: bool = False
class ShoppingListResponse(BaseModel):
week_start_date: date
items: List[ShoppingListItem]
total_estimated_cost: float
sale_items_count: int
by_aisle: dict[str, List[ShoppingListItem]]