Public Access
User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.
Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
(family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.
Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
(overlay on RecipeCard) and detail (text buttons in RecipeDetail
top bar). Popover with Allergy (red, window.confirm) + Dislike
(neutral, no confirm). Undo toast via showToast.undo() (Sprint 3
B12 pattern, 6s window). Pre-existing block detection shows a
Blocked state with an Unblock path.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
+ e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
of Add to Plan.
Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).
Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
173 lines
5.7 KiB
Python
173 lines
5.7 KiB
Python
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 Ingredient, NeverSuggest, NeverSuggestReason, Recipe
|
|
from app.schemas.never_suggest import NeverSuggestCreate, NeverSuggestRead
|
|
from app.security import require_admin, require_session
|
|
|
|
|
|
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}")
|
|
|
|
|
|
def _attach_names(rows: List[NeverSuggest], db: Session) -> List[dict]:
|
|
"""Hydrate recipe_name / ingredient_name for the response.
|
|
|
|
One LEFT OUTER JOIN per kind, then merge into the response dicts.
|
|
A second pass would be a `selectinload` if the list grows; for the
|
|
family-scale (dozens of rows) this is simpler and fast enough.
|
|
"""
|
|
recipe_ids = {r.recipe_id for r in rows if r.recipe_id}
|
|
ingredient_ids = {r.ingredient_id for r in rows if r.ingredient_id}
|
|
recipe_map: dict[UUID, str] = {}
|
|
if recipe_ids:
|
|
for rid, name in db.query(Recipe.id, Recipe.name).filter(Recipe.id.in_(recipe_ids)).all():
|
|
recipe_map[rid] = name
|
|
ingredient_map: dict[UUID, str] = {}
|
|
if ingredient_ids:
|
|
for iid, name in db.query(Ingredient.id, Ingredient.name).filter(Ingredient.id.in_(ingredient_ids)).all():
|
|
ingredient_map[iid] = name
|
|
out = []
|
|
for r in rows:
|
|
d = {
|
|
"id": r.id,
|
|
"family_profile_id": r.family_profile_id,
|
|
"ingredient_id": r.ingredient_id,
|
|
"recipe_id": r.recipe_id,
|
|
"reason": r.reason.value if r.reason else None,
|
|
"notes": r.notes,
|
|
"recipe_name": recipe_map.get(r.recipe_id) if r.recipe_id else None,
|
|
"ingredient_name": ingredient_map.get(r.ingredient_id) if r.ingredient_id else None,
|
|
}
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
@public_router.get("", response_model=List[NeverSuggestRead])
|
|
def list_for_family(
|
|
family_profile_id: UUID = Query(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = (
|
|
db.query(NeverSuggest)
|
|
.filter(NeverSuggest.family_profile_id == family_profile_id)
|
|
.all()
|
|
)
|
|
return _attach_names(rows, db)
|
|
|
|
|
|
@public_router.post("", response_model=NeverSuggestRead, status_code=status.HTTP_201_CREATED)
|
|
def add_block(
|
|
payload: NeverSuggestCreate,
|
|
db: Session = Depends(get_db),
|
|
_session: str = Depends(require_session),
|
|
):
|
|
"""Family-facing: mark a recipe (or ingredient) as never-suggest.
|
|
|
|
Idempotent on (family_profile_id, recipe_id, reason). Re-adding the
|
|
same row returns the existing row instead of creating a duplicate.
|
|
The on-conflict check is a single SELECT + INSERT; small enough
|
|
that we don't need a unique index.
|
|
"""
|
|
existing = (
|
|
db.query(NeverSuggest)
|
|
.filter(
|
|
NeverSuggest.family_profile_id == payload.family_profile_id,
|
|
NeverSuggest.recipe_id == payload.recipe_id,
|
|
NeverSuggest.ingredient_id == payload.ingredient_id,
|
|
NeverSuggest.reason == _coerce_reason(payload.reason),
|
|
)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
return _attach_names([existing], db)[0]
|
|
|
|
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 _attach_names([row], db)[0]
|
|
|
|
|
|
@public_router.delete(
|
|
"/{ns_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
response_class=Response,
|
|
)
|
|
def remove_block(
|
|
ns_id: UUID,
|
|
db: Session = Depends(get_db),
|
|
session: str = Depends(require_session),
|
|
) -> Response:
|
|
"""Family-facing: undo a never-suggest (used by the toast Undo button).
|
|
|
|
require_session resolves to the auto-detected family id. The row's
|
|
family_profile_id must match — otherwise a 403 prevents one family
|
|
from removing another family's block.
|
|
"""
|
|
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")
|
|
if str(row.family_profile_id) != session:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="never-suggest entry belongs to a different family",
|
|
)
|
|
db.delete(row)
|
|
db.commit()
|
|
return Response(status_code=204)
|
|
|
|
|
|
@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 _attach_names([row], db)[0]
|
|
|
|
|
|
@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)
|