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 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 __future__ import annotations
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])
def get_recipes(
skip: int = 0,
limit: int = 50,
cuisine_tag: Optional[str] = None,
dietary_tag: Optional[str] = None,
protein_type: Optional[str] = None,
db: Session = Depends(get_db)
public_router = APIRouter(prefix="/api/recipes", tags=["recipes"])
admin_router = APIRouter(
prefix="/api/admin/recipes",
tags=["recipes-admin"],
dependencies=[Depends(require_admin)],
)
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)
if cuisine_tag:
query = query.filter(Recipe.cuisine_tags.contains([cuisine_tag]))
if dietary_tag:
query = query.filter(Recipe.dietary_tags.contains([dietary_tag]))
if protein_type:
query = query.filter(Recipe.protein_type == protein_type)
recipes = query.offset(skip).limit(limit).all()
return recipes
if q:
like = f"%{q.lower()}%"
query = query.filter(Recipe.name.ilike(like))
rows = query.order_by(Recipe.name).limit(limit).all()
return [_serialize(r) for r in rows]
@router.get("/ingredients", response_model=List[IngredientResponse])
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)
@public_router.get("/{recipe_id}", response_model=RecipeRead)
def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
return recipe
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
return _serialize(row)
@router.post("", response_model=RecipeResponse, dependencies=[Depends(require_session)])
def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first()
db_recipe = Recipe(
family_profile_id=profile.id if profile else None,
name=recipe.name,
description=recipe.description,
image_url=recipe.image_url,
image_source=recipe.image_source,
prep_time_minutes=recipe.prep_time_minutes,
cook_time_minutes=recipe.cook_time_minutes,
servings=recipe.servings,
servings_scaled=recipe.servings_scaled,
cuisine_tags=recipe.cuisine_tags,
dietary_tags=recipe.dietary_tags,
protein_type=recipe.protein_type,
spice_level=recipe.spice_level,
ingredients=[ing.model_dump() for ing in recipe.ingredients],
instructions=recipe.instructions,
source_url=recipe.source_url,
is_manually_added=recipe.is_manually_added
@admin_router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED)
def create_recipe(payload: RecipeCreate, db: Session = Depends(get_db)):
_validate_ingredient_ids(db, payload.ingredients)
row = Recipe(
name=payload.name,
description=payload.description,
image_url=payload.image_url,
prep_time_minutes=payload.prep_time_minutes,
cook_time_minutes=payload.cook_time_minutes,
servings=payload.servings,
cuisine_tags=payload.cuisine_tags,
dietary_tags=payload.dietary_tags,
protein_type=payload.protein_type,
spice_level=payload.spice_level,
calories_per_serving=payload.calories_per_serving,
ingredients=[ref.model_dump(mode="json") for ref in payload.ingredients],
instructions=payload.instructions,
source_url=payload.source_url,
is_manually_added=True,
)
db.add(db_recipe)
db.add(row)
db.commit()
db.refresh(db_recipe)
return db_recipe
db.refresh(row)
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)):
recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
db.delete(recipe)
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
db.delete(row)
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)}
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 recipes as recipes_api
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
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(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"])
app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"])
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
app.include_router(ingredients_api.public_router)
app.include_router(ingredients_api.admin_router)
app.include_router(recipes_api.public_router)
app.include_router(recipes_api.admin_router)