Public Access
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.
61 lines
2.1 KiB
Python
61 lines
2.1 KiB
Python
from pydantic_settings import BaseSettings
|
|
from pydantic import model_validator
|
|
from pydantic import ConfigDict
|
|
from typing import Optional
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
# Required — fail fast at import time if unset.
|
|
DATABASE_URL: str = ""
|
|
|
|
SENDGRID_API_KEY: Optional[str] = None
|
|
SENDGRID_FROM_EMAIL: str = "peter@research.bike"
|
|
SENDGRID_REPLY_TO: str = "peter@research.bike"
|
|
EMAIL_BACKEND: str = "console"
|
|
LUCKY_CA_URL: str = "https://www.luckyncal.com"
|
|
# Swiftly product API (replaces Playwright path). The bearer JWT is
|
|
# auto-minted at request time via app.services.swiftly_auth.get_token().
|
|
LUCKY_STORE_ID: str = "757"
|
|
SWIFTLY_API_BASE: str = "https://prod.swiftlyapi.net"
|
|
SWIFTLY_CATEGORIES_URL: str = "https://luckysupermarkets.com/categories"
|
|
AI_IMAGE_ENABLED: bool = False
|
|
AI_IMAGE_PROVIDER: Optional[str] = None
|
|
AI_IMAGE_API_KEY: Optional[str] = None
|
|
LOG_LEVEL: str = "INFO"
|
|
SECRET_KEY: str = "dev-secret-key"
|
|
|
|
# Auth (R1-B+D)
|
|
ADMIN_TOKEN: str = ""
|
|
SESSION_PASSWORD: str = ""
|
|
ADMIN_EMAIL: str = ""
|
|
APP_BASE_URL: str = "http://localhost"
|
|
|
|
# Ollama Cloud LLM (used for ingredient→grocery LLM matching second pass)
|
|
OLLAMA_BASE_URL: str = "https://ollama.com/v1"
|
|
OLLAMA_API_KEY: Optional[str] = None
|
|
OLLAMA_MODEL: str = "kimi-k2.6:cloud"
|
|
|
|
# Sprint 12: Spoonacular external recipe search. Free tier is
|
|
# 150 points/day. ComplexSearch = 1 point + 0.01 per result. The
|
|
# /information endpoint = 1 point per call. The recipe_search
|
|
# router gates calls to stay under 140 points/day to leave a
|
|
# safety margin.
|
|
SPOONACULAR_API_KEY: Optional[str] = None
|
|
|
|
FAMILY_EMAIL_1: Optional[str] = None
|
|
FAMILY_EMAIL_2: Optional[str] = None
|
|
RECIPES_EMAIL: Optional[str] = None
|
|
|
|
model_config = ConfigDict(extra="ignore", env_file=".env")
|
|
|
|
@model_validator(mode="after")
|
|
def _require_database_url(self) -> "Settings":
|
|
if not self.DATABASE_URL:
|
|
raise RuntimeError("DATABASE_URL is required")
|
|
return self
|
|
|
|
|
|
settings = Settings()
|