Files
Meal-Planner/backend/app/api/recipes.py
T
adminandClaude Opus 4.7 f16a2f8710 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>
2026-05-06 06:14:37 -07:00

130 lines
4.6 KiB
Python

from __future__ import annotations
from typing import List, Optional
from uuid import UUID
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
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 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]
@public_router.get("/{recipe_id}", response_model=RecipeRead)
def get_recipe(recipe_id: UUID, 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")
return _serialize(row)
@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(row)
db.commit()
db.refresh(row)
return _serialize(row)
@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)):
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 Response(status_code=status.HTTP_204_NO_CONTENT)