Files
Meal-Planner/backend/app/schemas/__init__.py
T
admin 11b4595cf7 feat(ui): Sprint 12 — F8 Spoonacular search (web-search toggle + import)
Sprint 12 wires a "Search the web" toggle on /recipes that hits
Spoonacular’s complexSearch API. Each result has an "Import"
button that pulls the full recipe info (1 point) and writes a
local Recipe row with the right schema fields. Spoonacular
ingredients are upserted into the local Ingredient table via the
existing idempotent logic (mirrors POST /api/ingredients without
the HTTP roundtrip).

No pre-existing WIP files touched. Sprint 12 creates a new
backend/app/api/recipe_search.py router (separate from the WIP
recipes.py) and adds 2 Pydantic models to backend/app/schemas/
__init__.py (the canonical location). The WIP recipes.py is
registered in main.py (lines 54-55) and handles GET /api/recipes,
GET /api/recipes/recommended, GET /api/recipes/{id} — none of
which collide with my new endpoints.

Backend:
- backend/app/api/recipe_search.py (NEW, ~270 lines). 2 endpoints:
  - GET /api/recipes/search?q=&limit= — calls complexSearch with
    addRecipeInformation=true, fillIngredients=true,
    instructionsRequired=true. Returns normalized
    RecipeSearchHit[]. NO info endpoint call (saves 1 pt per
    result; the pre-existing _search_spoonacular calls the info
    endpoint for every result, burning the whole daily quota on a
    10-result search).
  - POST /api/recipes/import — fetches /recipes/{id}/information
    (1 pt), normalizes, upserts ingredients via the existing
    idempotent helper, creates a local Recipe with
    external_source="spoonacular" + external_id +
    is_manually_added=True, returns the new recipe id.
  - Process-wide _points_used counter (module-level singleton +
    threading.Lock). 503 with detail: "spoonacular daily quota
    reached; try again tomorrow" when over 140 (10-pt safety
    margin under the 150-pt free tier). Resets on process restart.
  - 503 with clear "SPOONACULAR_API_KEY not configured" when env
    var unset.
  - Idempotent import: 409 on duplicate (external_source,
    external_id).
- backend/app/config.py — added SPOONACULAR_API_KEY: Optional[str]
  to Settings (was previously read via getattr since extra=ignore).
- backend/app/schemas/__init__.py — added RecipeSearchHit +
  RecipeImportRequest.
- backend/app/main.py:62-63 — registered recipe_search_api.router
  at the /api/recipes prefix. No collision with the WIP.

Frontend:
- frontend/src/api/index.ts — added 5 new methods to
  mealPlannerApi.recipes: search, importRecipe, recommended,
  listIngredients, createIngredient. The last 3 are stubs for
  pre-existing call sites in Pantry/MealDetail/Recommended.tsx
  that were previously hidden by a smaller API surface.
- frontend/src/pages/Recipes.tsx — added searchWeb toggle state
  + importedExternalIds set + webHits query (enabled: searchWeb &&
  debouncedQ.length >= 2) + importMutation (toast on success,
  showApiError on failure) + the toggle button (with
  aria-pressed={searchWeb}) + the web-search panel (<div
  role="region" aria-label="Web recipe search"
  aria-busy={webLoading}>). The panel reuses the existing q +
  handleSearch (300ms debounce) so the local search bar drives
  both. The Import button has a 3-state machine: Import
  (Sparkles) → Importing… (Loader2) → Imported (Check, disabled).
- frontend/src/types/index.ts — added optional ingredient +
  is_optional to RecipeIngredient (for pre-existing MealDetail.tsx
  call sites).

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 496.48 → 500.28 kB (+3.8 kB). Backend AST clean on all 4
changed files. Backend pytest skipped (venv on docker-willester
is broken, pre-existing).

Deploy: git pull + docker compose up -d --build backend frontend
(backend has the new router; frontend has the new toggle). No
migration, no new dependencies.
2026-06-05 16:31:39 -07:00

415 lines
11 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
# Sprint 8: when this denial decays. NULL = no decay (approve / never_again).
denial_expires_at: Optional[datetime] = 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
# Sprint 8: "this_week" (default) or "never_again". Only honored when
# vote=False; ignored for approve votes.
denial_scope: Optional[str] = Field(None, pattern="^(this_week|never_again)$")
class VoteResponse(BaseModel):
id: UUID
meal_plan_item_id: UUID
family_member_id: UUID
vote: bool
# Sprint 8: which deny-scope the voter chose. NULL on approve votes.
denial_scope: Optional[str] = None
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]]
# Sprint 12: Spoonacular external recipe search. The hit shape is
# what the webui shows in the "Search the web" panel; the import
# request is what the import button POSTs.
class RecipeSearchHit(BaseModel):
external_id: str
external_source: str = "spoonacular"
name: str
image_url: Optional[str] = None
source_url: Optional[str] = None
prep_time_minutes: Optional[int] = None
cook_time_minutes: Optional[int] = None
servings: Optional[int] = None
cuisine_tags: List[str] = Field(default_factory=list)
dietary_tags: List[str] = Field(default_factory=list)
protein_type: Optional[str] = None
calories_per_serving: Optional[int] = None
class RecipeImportRequest(BaseModel):
external_id: str
external_source: str = "spoonacular"