diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py index 39abb3a..070c876 100644 --- a/backend/app/api/recipes.py +++ b/backend/app/api/recipes.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 1565a65..cac08ca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/tests/test_recipe_api.py b/backend/tests/test_recipe_api.py new file mode 100644 index 0000000..4e78eb4 --- /dev/null +++ b/backend/tests/test_recipe_api.py @@ -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