From b1ea011d49d0f2266d33487a5fc33aa066af7f74 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 5 May 2026 20:54:31 -0700 Subject: [PATCH] feat: ingredient CRUD endpoints with admin gating --- backend/app/api/ingredients.py | 107 +++++++++++++++++++++++++++ backend/app/main.py | 3 + backend/tests/test_ingredient_api.py | 76 +++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 backend/app/api/ingredients.py create mode 100644 backend/tests/test_ingredient_api.py diff --git a/backend/app/api/ingredients.py b/backend/app/api/ingredients.py new file mode 100644 index 0000000..623ba96 --- /dev/null +++ b/backend/app/api/ingredients.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import Ingredient +from app.schemas.ingredient import IngredientCreate, IngredientRead, IngredientUpdate +from app.security import require_admin + + +public_router = APIRouter(prefix="/api/ingredients", tags=["ingredients"]) +admin_router = APIRouter( + prefix="/api/admin/ingredients", + tags=["ingredients-admin"], + dependencies=[Depends(require_admin)], +) + + +@public_router.get("", response_model=List[IngredientRead]) +def list_ingredients( + q: Optional[str] = Query(default=None), + limit: int = Query(default=100, le=500), + db: Session = Depends(get_db), +) -> List[Ingredient]: + query = db.query(Ingredient) + if q: + like = f"%{q.lower()}%" + query = query.filter( + or_( + Ingredient.name_lower.ilike(like), + Ingredient.aliases.any(q), + ) + ) + return query.order_by(Ingredient.name).limit(limit).all() + + +@public_router.get("/{ingredient_id}", response_model=IngredientRead) +def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingredient: + row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() + if row is None: + raise HTTPException(status_code=404, detail="ingredient not found") + return row + + +@admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) +def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: + row = Ingredient( + name=payload.name, + name_lower=payload.name.lower(), + aliases=payload.aliases, + aisle=payload.aisle, + unit=payload.unit, + typical_price=payload.typical_price, + ) + db.add(row) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="ingredient name already exists") + db.refresh(row) + return row + + +@admin_router.patch("/{ingredient_id}", response_model=IngredientRead) +def update_ingredient( + ingredient_id: UUID, + payload: IngredientUpdate, + db: Session = Depends(get_db), +) -> Ingredient: + row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() + if row is None: + raise HTTPException(status_code=404, detail="ingredient not found") + data = payload.model_dump(exclude_unset=True) + if "name" in data: + row.name = data["name"] + row.name_lower = data["name"].lower() + for field in ("aliases", "aisle", "unit", "typical_price"): + if field in data: + setattr(row, field, data[field]) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="ingredient name conflict") + db.refresh(row) + return row + + +@admin_router.delete( + "/{ingredient_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_class=Response, +) +def delete_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)): + row = db.query(Ingredient).filter(Ingredient.id == ingredient_id).first() + if row is None: + raise HTTPException(status_code=404, detail="ingredient not found") + db.delete(row) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/main.py b/backend/app/main.py index 9c07191..1565a65 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,7 @@ def health_check_db(db: Session = Depends(get_db)): from app.api import profile, recipes, meals, shopping_list, pantry, admin, auth +from app.api import ingredients as ingredients_api app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) @@ -38,3 +39,5 @@ 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) diff --git a/backend/tests/test_ingredient_api.py b/backend/tests/test_ingredient_api.py new file mode 100644 index 0000000..b6a36a5 --- /dev/null +++ b/backend/tests/test_ingredient_api.py @@ -0,0 +1,76 @@ +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin_headers() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def test_create_ingredient_returns_201_with_id(client): + body = { + "name": "Test Chicken Thighs", + "aliases": ["chicken thigh", "BSL chicken thighs"], + "aisle": "meat_seafood", + "unit": "lb", + } + r = client.post("/api/admin/ingredients", json=body, headers=_admin_headers()) + assert r.status_code == 201, r.text + data = r.json() + assert data["id"] + assert data["aliases"] == ["chicken thigh", "BSL chicken thighs"] + + +def test_create_ingredient_rejects_duplicate_name(client): + body = {"name": "Test Garlic Bulb", "aliases": [], "aisle": "produce", "unit": "clove"} + r1 = client.post("/api/admin/ingredients", json=body, headers=_admin_headers()) + assert r1.status_code == 201 + r2 = client.post("/api/admin/ingredients", json=body, headers=_admin_headers()) + assert r2.status_code == 409 + + +def test_list_ingredients_supports_search(client): + client.post( + "/api/admin/ingredients", + json={"name": "Test Avocado Oil", "aliases": ["EVOO-test"], "aisle": "pantry", "unit": "tbsp"}, + headers=_admin_headers(), + ) + r = client.get("/api/ingredients?q=avocado") + assert r.status_code == 200 + names = {row["name"] for row in r.json()} + assert "Test Avocado Oil" in names + + +def test_create_ingredient_requires_admin_token(client): + body = {"name": "Test Meyer Lemon", "aisle": "produce", "unit": "ea", "aliases": []} + r = client.post("/api/admin/ingredients", json=body) + assert r.status_code == 401 + + +def test_update_ingredient_replaces_aliases(client): + create = client.post( + "/api/admin/ingredients", + json={"name": "Onion, Yellow", "aliases": ["yellow onion"], "aisle": "produce", "unit": "ea"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + r = client.patch( + f"/api/admin/ingredients/{iid}", + json={"aliases": ["yellow onion", "spanish onion"]}, + headers=_admin_headers(), + ) + assert r.status_code == 200 + assert r.json()["aliases"] == ["yellow onion", "spanish onion"] + + +def test_delete_ingredient_removes_row(client): + create = client.post( + "/api/admin/ingredients", + json={"name": "Sage", "aliases": [], "aisle": "produce", "unit": "tsp"}, + headers=_admin_headers(), + ) + iid = create.json()["id"] + r = client.delete(f"/api/admin/ingredients/{iid}", headers=_admin_headers()) + assert r.status_code == 204 + r2 = client.get(f"/api/ingredients/{iid}") + assert r2.status_code == 404