Public Access
feat: never-suggest CRUD endpoints (ingredient and recipe blocklist)
This commit is contained in:
@@ -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)
|
||||||
@@ -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 profile, meals, shopping_list, pantry, admin, auth
|
||||||
from app.api import ingredients as ingredients_api
|
from app.api import ingredients as ingredients_api
|
||||||
from app.api import recipes as recipes_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(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
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(ingredients_api._match_admin_router)
|
||||||
app.include_router(recipes_api.public_router)
|
app.include_router(recipes_api.public_router)
|
||||||
app.include_router(recipes_api.admin_router)
|
app.include_router(recipes_api.admin_router)
|
||||||
|
app.include_router(never_suggest_api.public_router)
|
||||||
|
app.include_router(never_suggest_api.admin_router)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user