From 1f7b9bac23a60ff5c484de062dadaaa60c39cd39 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 06:26:16 -0700 Subject: [PATCH] feat: never-suggest CRUD endpoints (ingredient and recipe blocklist) --- backend/app/api/never_suggest.py | 70 ++++++++++++++++++++++ backend/app/main.py | 3 + backend/app/schemas/never_suggest.py | 32 ++++++++++ backend/tests/test_never_suggest_api.py | 77 +++++++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 backend/app/api/never_suggest.py create mode 100644 backend/app/schemas/never_suggest.py create mode 100644 backend/tests/test_never_suggest_api.py diff --git a/backend/app/api/never_suggest.py b/backend/app/api/never_suggest.py new file mode 100644 index 0000000..6361dde --- /dev/null +++ b/backend/app/api/never_suggest.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import List +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 NeverSuggest, NeverSuggestReason +from app.schemas.never_suggest import NeverSuggestCreate, NeverSuggestRead +from app.security import require_admin + + +public_router = APIRouter(prefix="/api/never-suggest", tags=["never-suggest"]) +admin_router = APIRouter( + prefix="/api/admin/never-suggest", + tags=["never-suggest-admin"], + dependencies=[Depends(require_admin)], +) + + +def _coerce_reason(raw: str | None) -> NeverSuggestReason | None: + if raw is None: + return None + try: + return NeverSuggestReason(raw) + except ValueError: + raise HTTPException(status_code=422, detail=f"unknown reason: {raw}") + + +@public_router.get("", response_model=List[NeverSuggestRead]) +def list_for_family( + family_profile_id: UUID = Query(...), + db: Session = Depends(get_db), +): + return ( + db.query(NeverSuggest) + .filter(NeverSuggest.family_profile_id == family_profile_id) + .all() + ) + + +@admin_router.post("", response_model=NeverSuggestRead, status_code=status.HTTP_201_CREATED) +def block(payload: NeverSuggestCreate, db: Session = Depends(get_db)): + row = NeverSuggest( + family_profile_id=payload.family_profile_id, + ingredient_id=payload.ingredient_id, + recipe_id=payload.recipe_id, + reason=_coerce_reason(payload.reason), + notes=payload.notes, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +@admin_router.delete( + "/{ns_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_class=Response, +) +def unblock(ns_id: UUID, db: Session = Depends(get_db)) -> Response: + row = db.query(NeverSuggest).filter(NeverSuggest.id == ns_id).first() + if row is None: + raise HTTPException(status_code=404, detail="never-suggest entry not found") + db.delete(row) + db.commit() + return Response(status_code=204) diff --git a/backend/app/main.py b/backend/app/main.py index dce5ea7..94b51c5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,7 @@ def health_check_db(db: Session = Depends(get_db)): 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 +from app.api import never_suggest as never_suggest_api app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) @@ -44,3 +45,5 @@ app.include_router(ingredients_api.admin_router) app.include_router(ingredients_api._match_admin_router) app.include_router(recipes_api.public_router) app.include_router(recipes_api.admin_router) +app.include_router(never_suggest_api.public_router) +app.include_router(never_suggest_api.admin_router) diff --git a/backend/app/schemas/never_suggest.py b/backend/app/schemas/never_suggest.py new file mode 100644 index 0000000..55ec941 --- /dev/null +++ b/backend/app/schemas/never_suggest.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + + +class NeverSuggestCreate(BaseModel): + family_profile_id: UUID + ingredient_id: Optional[UUID] = None + recipe_id: Optional[UUID] = None + reason: Optional[str] = Field(default=None, max_length=50) + notes: Optional[str] = None + + @model_validator(mode="after") + def _exactly_one_target(self) -> "NeverSuggestCreate": + present = sum(x is not None for x in (self.ingredient_id, self.recipe_id)) + if present != 1: + raise ValueError("exactly one of ingredient_id or recipe_id must be set") + return self + + +class NeverSuggestRead(BaseModel): + id: UUID + family_profile_id: UUID + ingredient_id: Optional[UUID] = None + recipe_id: Optional[UUID] = None + reason: Optional[str] = None + notes: Optional[str] = None + + model_config = {"from_attributes": True} diff --git a/backend/tests/test_never_suggest_api.py b/backend/tests/test_never_suggest_api.py new file mode 100644 index 0000000..be9ab82 --- /dev/null +++ b/backend/tests/test_never_suggest_api.py @@ -0,0 +1,77 @@ +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def _admin() -> dict: + return {"Authorization": "Bearer test-admin-token"} + + +def _seed_family(db_session) -> str: + from uuid import uuid4 + from app.models import FamilyProfile + + fid = uuid4() + db_session.add( + FamilyProfile( + id=fid, + name="Test Family NS", + household_size=4, + adult_count=2, + child_count=2, + calorie_target=2400, + ) + ) + db_session.commit() + return str(fid) + + +def test_block_ingredient(client, db_session): + fid = _seed_family(db_session) + ing = client.post( + "/api/admin/ingredients", + json={"name": "Test NS Mushrooms", "aliases": [], "aisle": "produce", "unit": "oz"}, + headers=_admin(), + ) + iid = ing.json()["id"] + + r = client.post( + "/api/admin/never-suggest", + json={"family_profile_id": fid, "ingredient_id": iid, "reason": "dislike"}, + headers=_admin(), + ) + assert r.status_code == 201, r.text + + +def test_list_never_suggest_for_family(client, db_session): + fid = _seed_family(db_session) + ing = client.post( + "/api/admin/ingredients", + json={"name": "Test NS Cilantro", "aliases": [], "aisle": "produce", "unit": "tbsp"}, + headers=_admin(), + ) + client.post( + "/api/admin/never-suggest", + json={"family_profile_id": fid, "ingredient_id": ing.json()["id"], "reason": "dislike"}, + headers=_admin(), + ) + r = client.get(f"/api/never-suggest?family_profile_id={fid}") + assert r.status_code == 200 + assert len(r.json()) >= 1 + + +def test_unblock_removes_row(client, db_session): + fid = _seed_family(db_session) + ing = client.post( + "/api/admin/ingredients", + json={"name": "Test NS Anchovy", "aliases": [], "aisle": "pantry", "unit": "ea"}, + headers=_admin(), + ) + create = client.post( + "/api/admin/never-suggest", + json={"family_profile_id": fid, "ingredient_id": ing.json()["id"], "reason": "dislike"}, + headers=_admin(), + ) + nid = create.json()["id"] + r = client.delete(f"/api/admin/never-suggest/{nid}", headers=_admin()) + assert r.status_code == 204