feat: recipe CRUD endpoints with canonical ingredient validation

POST/PATCH validate every ingredient_id against the ingredient table
and return 422 with the missing list when refs don't resolve. Replaces
the prior recipes.py stub. Public read routes + admin write routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 06:14:37 -07:00
co-authored by Claude Opus 4.7
parent b1ea011d49
commit f16a2f8710
3 changed files with 199 additions and 97 deletions
+112 -95
View File
@@ -1,112 +1,129 @@
from fastapi import APIRouter, Depends, HTTPException from __future__ import annotations
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import Recipe, FamilyProfile
from app.schemas import RecipeResponse, RecipeCreate, IngredientCreate, IngredientResponse
from app.security import require_session
from uuid import UUID
from typing import List, Optional from typing import List, Optional
from uuid import UUID
router = APIRouter() from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Ingredient, Recipe
from app.schemas.recipe import RecipeCreate, RecipeRead, RecipeUpdate
from app.security import require_admin
@router.get("", response_model=List[RecipeResponse]) public_router = APIRouter(prefix="/api/recipes", tags=["recipes"])
def get_recipes( admin_router = APIRouter(
skip: int = 0, prefix="/api/admin/recipes",
limit: int = 50, tags=["recipes-admin"],
cuisine_tag: Optional[str] = None, dependencies=[Depends(require_admin)],
dietary_tag: Optional[str] = None, )
protein_type: Optional[str] = None,
db: Session = Depends(get_db)
def _validate_ingredient_ids(db: Session, ingredient_refs) -> None:
ids = [ref.ingredient_id for ref in ingredient_refs]
found = (
db.query(Ingredient.id).filter(Ingredient.id.in_(ids)).all()
)
found_ids = {row[0] for row in found}
missing = [str(i) for i in ids if i not in found_ids]
if missing:
raise HTTPException(
status_code=422,
detail={"error": "unknown ingredient_ids", "missing": missing},
)
def _serialize(row: Recipe) -> dict:
return {
"id": row.id,
"name": row.name,
"description": row.description,
"image_url": row.image_url,
"prep_time_minutes": row.prep_time_minutes or 0,
"cook_time_minutes": row.cook_time_minutes or 0,
"servings": row.servings,
"cuisine_tags": list(row.cuisine_tags or []),
"dietary_tags": list(row.dietary_tags or []),
"protein_type": row.protein_type,
"spice_level": row.spice_level,
"calories_per_serving": row.calories_per_serving,
"ingredients": row.ingredients or [],
"instructions": list(row.instructions or []),
"source_url": row.source_url,
}
@public_router.get("", response_model=List[RecipeRead])
def list_recipes(
q: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
): ):
query = db.query(Recipe) query = db.query(Recipe)
if q:
if cuisine_tag: like = f"%{q.lower()}%"
query = query.filter(Recipe.cuisine_tags.contains([cuisine_tag])) query = query.filter(Recipe.name.ilike(like))
if dietary_tag: rows = query.order_by(Recipe.name).limit(limit).all()
query = query.filter(Recipe.dietary_tags.contains([dietary_tag])) return [_serialize(r) for r in rows]
if protein_type:
query = query.filter(Recipe.protein_type == protein_type)
recipes = query.offset(skip).limit(limit).all()
return recipes
@router.get("/ingredients", response_model=List[IngredientResponse]) @public_router.get("/{recipe_id}", response_model=RecipeRead)
def list_ingredients(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
from app.models import Ingredient
ingredients = db.query(Ingredient).offset(skip).limit(limit).all()
return ingredients
@router.post("/ingredients", response_model=IngredientResponse, dependencies=[Depends(require_session)])
def create_ingredient(ingredient: IngredientCreate, db: Session = Depends(get_db)):
from app.models import Ingredient
existing = db.query(Ingredient).filter(Ingredient.name_lower == ingredient.name_lower).first()
if existing:
raise HTTPException(status_code=400, detail="Ingredient with this name already exists")
db_ingredient = Ingredient(
name=ingredient.name,
name_lower=ingredient.name_lower,
plural_name=ingredient.plural_name,
aisle=ingredient.aisle,
typical_price=ingredient.typical_price,
unit=ingredient.unit,
season_months=ingredient.season_months
)
db.add(db_ingredient)
db.commit()
db.refresh(db_ingredient)
return db_ingredient
@router.get("/{recipe_id}", response_model=RecipeResponse)
def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)): def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if not recipe: if row is None:
raise HTTPException(status_code=404, detail="Recipe not found") raise HTTPException(status_code=404, detail="recipe not found")
return recipe return _serialize(row)
@router.post("", response_model=RecipeResponse, dependencies=[Depends(require_session)]) @admin_router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED)
def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)): def create_recipe(payload: RecipeCreate, db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first() _validate_ingredient_ids(db, payload.ingredients)
row = Recipe(
db_recipe = Recipe( name=payload.name,
family_profile_id=profile.id if profile else None, description=payload.description,
name=recipe.name, image_url=payload.image_url,
description=recipe.description, prep_time_minutes=payload.prep_time_minutes,
image_url=recipe.image_url, cook_time_minutes=payload.cook_time_minutes,
image_source=recipe.image_source, servings=payload.servings,
prep_time_minutes=recipe.prep_time_minutes, cuisine_tags=payload.cuisine_tags,
cook_time_minutes=recipe.cook_time_minutes, dietary_tags=payload.dietary_tags,
servings=recipe.servings, protein_type=payload.protein_type,
servings_scaled=recipe.servings_scaled, spice_level=payload.spice_level,
cuisine_tags=recipe.cuisine_tags, calories_per_serving=payload.calories_per_serving,
dietary_tags=recipe.dietary_tags, ingredients=[ref.model_dump(mode="json") for ref in payload.ingredients],
protein_type=recipe.protein_type, instructions=payload.instructions,
spice_level=recipe.spice_level, source_url=payload.source_url,
ingredients=[ing.model_dump() for ing in recipe.ingredients], is_manually_added=True,
instructions=recipe.instructions,
source_url=recipe.source_url,
is_manually_added=recipe.is_manually_added
) )
db.add(row)
db.add(db_recipe)
db.commit() db.commit()
db.refresh(db_recipe) db.refresh(row)
return db_recipe return _serialize(row)
@router.delete("/{recipe_id}", dependencies=[Depends(require_session)]) @admin_router.patch("/{recipe_id}", response_model=RecipeRead)
def update_recipe(recipe_id: UUID, payload: RecipeUpdate, db: Session = Depends(get_db)):
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
data = payload.model_dump(exclude_unset=True)
if "ingredients" in data and data["ingredients"] is not None:
_validate_ingredient_ids(db, payload.ingredients)
row.ingredients = [ref.model_dump(mode="json") for ref in payload.ingredients]
data.pop("ingredients")
for field, value in data.items():
setattr(row, field, value)
db.commit()
db.refresh(row)
return _serialize(row)
@admin_router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)): def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if not recipe: if row is None:
raise HTTPException(status_code=404, detail="Recipe not found") raise HTTPException(status_code=404, detail="recipe not found")
db.delete(row)
db.delete(recipe)
db.commit() db.commit()
return {"message": "Recipe deleted"} return Response(status_code=status.HTTP_204_NO_CONTENT)
+4 -2
View File
@@ -29,15 +29,17 @@ def health_check_db(db: Session = Depends(get_db)):
return {"status": "error", "database": "disconnected", "error": str(e)} return {"status": "error", "database": "disconnected", "error": str(e)}
from app.api import profile, recipes, meals, shopping_list, pantry, admin, auth from app.api import profile, meals, shopping_list, pantry, admin, auth
from app.api import ingredients as ingredients_api from app.api import ingredients as ingredients_api
from app.api import recipes as recipes_api
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(recipes.router, prefix="/api/recipes", tags=["recipes"])
app.include_router(meals.router, prefix="/api/meals", tags=["meals"]) app.include_router(meals.router, prefix="/api/meals", tags=["meals"])
app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"]) app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"])
app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"]) app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(ingredients_api.public_router) app.include_router(ingredients_api.public_router)
app.include_router(ingredients_api.admin_router) app.include_router(ingredients_api.admin_router)
app.include_router(recipes_api.public_router)
app.include_router(recipes_api.admin_router)
+83
View File
@@ -0,0 +1,83 @@
import pytest
pytestmark = pytest.mark.requires_postgres
def _admin_headers() -> dict:
return {"Authorization": "Bearer test-admin-token"}
def _new_ingredient(client, name: str) -> str:
r = client.post(
"/api/admin/ingredients",
json={"name": name, "aliases": [], "aisle": "pantry", "unit": "ea"},
headers=_admin_headers(),
)
assert r.status_code == 201, r.text
return r.json()["id"]
def test_create_recipe_with_canonical_ingredients(client):
chicken = _new_ingredient(client, "Test Chicken Thighs Recipe1")
olive_oil = _new_ingredient(client, "Test Olive Oil Recipe1")
body = {
"name": "Test Sheet-Pan Chicken",
"prep_time_minutes": 10,
"cook_time_minutes": 30,
"servings": 4,
"cuisine_tags": ["american"],
"dietary_tags": [],
"protein_type": "chicken",
"calories_per_serving": 520,
"ingredients": [
{"ingredient_id": chicken, "qty": 2.0, "unit": "lb"},
{"ingredient_id": olive_oil, "qty": 2.0, "unit": "tbsp"},
],
"instructions": ["Preheat oven to 425", "Roast 30 min"],
}
r = client.post("/api/admin/recipes", json=body, headers=_admin_headers())
assert r.status_code == 201, r.text
data = r.json()
assert data["id"]
assert len(data["ingredients"]) == 2
def test_create_recipe_rejects_unknown_ingredient_id(client):
body = {
"name": "Test Bogus Recipe",
"prep_time_minutes": 5,
"cook_time_minutes": 5,
"servings": 4,
"cuisine_tags": [],
"dietary_tags": [],
"protein_type": "vegetarian",
"ingredients": [
{"ingredient_id": "00000000-0000-0000-0000-000000000000", "qty": 1, "unit": "ea"}
],
"instructions": ["nope"],
}
r = client.post("/api/admin/recipes", json=body, headers=_admin_headers())
assert r.status_code == 422
def test_list_recipes_returns_seeded_data(client):
chicken = _new_ingredient(client, "Test Chicken Thighs Recipe2")
client.post(
"/api/admin/recipes",
json={
"name": "Test Listable Recipe",
"prep_time_minutes": 5,
"cook_time_minutes": 25,
"servings": 4,
"cuisine_tags": ["american"],
"dietary_tags": [],
"protein_type": "chicken",
"ingredients": [{"ingredient_id": chicken, "qty": 1, "unit": "lb"}],
"instructions": ["cook"],
},
headers=_admin_headers(),
)
r = client.get("/api/recipes")
assert r.status_code == 200
names = {row["name"] for row in r.json()}
assert "Test Listable Recipe" in names