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).
40 lines
1.3 KiB
Python
40 lines
1.3 KiB
Python
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
|
|
# Server-side joins. Populated by the router via a single LEFT OUTER
|
|
# JOIN to recipe / ingredient; falls back to None when the target
|
|
# row has been deleted (the FK is ON DELETE CASCADE so the
|
|
# NeverSuggest row goes with it; this is belt-and-suspenders for
|
|
# the rare in-flight case).
|
|
recipe_name: Optional[str] = None
|
|
ingredient_name: Optional[str] = None
|
|
|
|
model_config = {"from_attributes": True}
|