feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)

User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'

The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.

Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
  denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.

Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.

Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
  and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
  _has_prior_active_soft_denial) — single source of truth for the
  deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
  (default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
  Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
  one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
  plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
  is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
  / Never again) for pending items. handleDeny is scope-aware;
  toast reflects promoted_to_permanent. window.confirm on
  'Never again' prevents accidental permanent blocks.

Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
  failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
  curls + email-render procedure + rollback.

Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
  380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md

Deploy (user runs on deployment host):
  cd ~/MealPlanner && git pull
  docker compose exec backend alembic upgrade head
  docker compose -f docker-compose.yml up -d --build backend frontend
This commit is contained in:
MealPlanner
2026-06-05 10:24:35 -07:00
parent 09c7525a12
commit efd1fc695f
15 changed files with 1063 additions and 96 deletions
@@ -0,0 +1,74 @@
"""Add denial_expires_at and denial_scope for Sprint 8 deny-semantics.
Revision ID: 0016
Revises: 0015
Create Date: 2026-06-05
Sprint 8 (user policy decision: "Hard filter. If it is denied this week twice,
it should be considered denied for good."):
- meal_plan_item.denial_expires_at TIMESTAMPTZ NULL
- NULL on existing rows and on "Never again" denials (no decay; permanent).
- now() + 90 days on "Deny this week" denials.
- The planner's _load_soft_denied_recipes() filters
`denial_expires_at > now()` to find still-active soft denials.
- When a "Deny this week" finds a prior active soft denial for the
same (family, recipe), the API promotes the denial to permanent:
denial_expires_at -> NULL + a NeverSuggest row is inserted.
- meal_plan_vote.denial_scope VARCHAR(16) NULL
- NULL on approve votes.
- "this_week" or "never_again" on deny votes. Captures the user's
intent at vote time (per-voter audit trail).
No data migration needed for existing rows:
- Existing 1 denied item (2026-05-15 day-2 Roasted Sweet Potato and
Chickpea Bowl) keeps denial_expires_at = NULL, which means it is NOT
in the "soft denied" pool (filter requires > now()). Effectively
forgotten after this migration. If the user wants it remembered,
they can re-trigger the soft-deny cycle.
- Existing meal_plan_vote rows keep denial_scope = NULL, which is
interpreted as "approve" (vote column is the source of truth).
Deploy via Docker (the db runs inside a container; no host psql required):
docker compose exec backend alembic upgrade head
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"meal_plan_item",
sa.Column("denial_expires_at", sa.DateTime(timezone=True), nullable=True),
)
# Partial index: only rows with a non-NULL denial_expires_at are
# queried by the planner. Reduces index size and lookup cost.
op.create_index(
"ix_meal_plan_item_denial_expires_at",
"meal_plan_item",
["denial_expires_at"],
postgresql_where=sa.text("denial_expires_at IS NOT NULL"),
)
op.add_column(
"meal_plan_vote",
sa.Column("denial_scope", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("meal_plan_vote", "denial_scope")
op.drop_index(
"ix_meal_plan_item_denial_expires_at",
table_name="meal_plan_item",
)
op.drop_column("meal_plan_item", "denial_expires_at")
+294 -37
View File
@@ -2,13 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse
from html import escape as _html_escape
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy import text, func
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import (
MealPlan, MealPlanItem, MealPlanVote, Recipe, Ingredient,
FamilyProfile, FamilyMember, ApprovalToken,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus
FamilyProfile, FamilyMember, ApprovalToken, NeverSuggest, NeverSuggestReason,
MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus,
)
from app.schemas import (
MealPlanResponse, MealPlanCreate,
@@ -21,8 +21,8 @@ from app.services import approval as approval_service
from app.services.feedback_analyzer import FeedbackAnalyzer
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta, timezone
from datetime import date
from uuid import UUID
from typing import List, Optional
import random
@@ -30,6 +30,107 @@ import random
router = APIRouter()
# Sprint 8 — soft-denial decay window. A "Deny this week" creates a row
# with `denial_expires_at = now() + 90d`; after that the recipe is
# eligible again. Two denials in this window promote the recipe to a
# permanent `NeverSuggest` block (user decision: hard filter).
DENIAL_DECAY_DAYS = 90
def _ensure_never_suggest_recipe(
db: Session, family_id, recipe_id, reason: NeverSuggestReason = NeverSuggestReason.DISLIKE
) -> bool:
"""Insert a NeverSuggest row for (family, recipe) if one doesn't exist.
Idempotent: returns True if a new row was inserted, False if one
already existed. Used by the auto-promotion logic in deny_meal_item
and submit_vote when a recipe is denied for the 2nd time within the
decay window.
"""
existing = (
db.query(NeverSuggest)
.filter(
NeverSuggest.family_profile_id == family_id,
NeverSuggest.recipe_id == recipe_id,
)
.first()
)
if existing is not None:
return False
db.add(NeverSuggest(
family_profile_id=family_id,
recipe_id=recipe_id,
reason=reason,
))
return True
def _has_prior_active_soft_denial(
db: Session, family_id, recipe_id, current_item_id: Optional[UUID] = None
) -> bool:
"""True if there is at least one *still-active* soft denial for this
recipe for this family. Active = approval_status='denied' AND
denial_expires_at > now(). Excludes the current row by default so
the auto-promotion check is correct on the first call."""
q = (
db.query(func.count(MealPlanItem.id))
.join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
.filter(
MealPlan.family_profile_id == family_id,
MealPlanItem.recipe_id == recipe_id,
MealPlanItem.approval_status == MealPlanItemStatus.denied,
MealPlanItem.denial_expires_at.isnot(None),
MealPlanItem.denial_expires_at > func.now(),
)
)
if current_item_id is not None:
q = q.filter(MealPlanItem.id != current_item_id)
return (q.scalar() or 0) > 0
def _apply_denial(
db: Session,
item: MealPlanItem,
scope: str,
) -> dict:
"""Apply a denial to `item` and (if scope=never_again or this is the
2nd denial within the decay window) promote the recipe to a permanent
block. Returns a dict describing what happened — used by both the
webui and the email vote paths to surface a clear toast / message.
scope values:
- "this_week" (default): set denial_expires_at = now() + 90d.
If a prior active soft denial exists, promote to permanent.
- "never_again": write a NeverSuggest row; set denial_expires_at
to NULL (signals "permanent, no decay").
"""
family_id = item.meal_plan.family_profile_id
recipe_id = item.recipe_id
promoted = False
if scope == "never_again":
_ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
item.denial_expires_at = None
promoted = True
else: # this_week
if _has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=item.id):
# 2nd denial in 90d → promote to permanent.
_ensure_never_suggest_recipe(db, family_id, recipe_id, NeverSuggestReason.DISLIKE)
item.denial_expires_at = None
promoted = True
else:
item.denial_expires_at = datetime.now(timezone.utc) + timedelta(days=DENIAL_DECAY_DAYS)
item.approval_status = MealPlanItemStatus.denied
db.commit()
db.refresh(item)
return {
"item": item,
"promoted_to_permanent": promoted,
"scope": scope,
}
@router.get("", response_model=Optional[MealPlanResponse])
def get_planned_meals(
week_start: Optional[date] = Query(
@@ -122,16 +223,32 @@ _DAY_NAMES = {
class VoteSubmission(BaseModel):
"""Body for POST /vote/{item_id}?token=...
Spec body: {"vote": "approve" | "deny"}.
Spec body: {"vote": "approve" | "deny" | "never_again"}.
Sprint 8 added "never_again" as a separate vote value (vs. plain
"deny" which is the soft "this week" denial). The vote path is the
same; the response carries `promoted_to_permanent` and `denial_scope`
so the email confirmation page can show what was applied.
"""
vote: str = Field(..., pattern="^(approve|deny)$")
vote: str = Field(..., pattern="^(approve|deny|never_again)$")
@router.get("/vote/{item_id}", response_class=HTMLResponse)
def get_vote_page(
item_id: UUID,
token: str = Query(..., description="Per-voter signed token"),
scope: Optional[str] = Query(
None,
pattern="^(approve|this_week|never_again)$",
description=(
"Sprint 8: when present, the GET is treated as a one-click "
"direct vote from an email link. The token is consumed, the "
"vote is recorded via submit_vote(), and a tiny confirmation "
"page is rendered. When absent, the page is the full vote "
"form with 3 buttons."
),
),
db: Session = Depends(get_db),
):
"""Render the per-voter approval confirmation page.
@@ -171,6 +288,67 @@ def get_vote_page(
safe_recipe = _html_escape(recipe_name)
safe_day = _html_escape(day_name)
safe_meal = _html_escape(meal_type)
# Sprint 8: one-click direct vote (used by the email's per-button
# links). Consume the token, record the vote via submit_vote, and
# render a tiny confirmation page. Single-use enforcement is shared
# with the JSON path (consume_token).
if scope is not None:
# Map email-link scope to the vote-payload "vote" field.
vote_value = "approve" if scope == "approve" else scope # "this_week" or "never_again"
result = submit_vote(
item_id=item_id,
submission=VoteSubmission(vote=vote_value),
token=token,
db=db,
)
item_status = result.get("item_status", "?")
promoted = result.get("promoted_to_permanent", False)
if scope == "approve":
msg = f"Approved {safe_recipe} ({safe_day})."
elif scope == "this_week":
if promoted:
msg = (
f"Denied {safe_recipe} for this week. "
f"You've denied this recipe recently, so it will not be "
f"suggested again (permanently blocked)."
)
else:
msg = (
f"Denied {safe_recipe} for this week. "
f"It will not be re-suggested for 90 days unless denied again."
)
else: # never_again
msg = (
f"Denied {safe_recipe} permanently. "
f"It will never be suggested again."
)
confirmation = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vote recorded</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{ font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto;
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
.status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
<body>
<h1>Hi {safe_voter}, your vote was recorded</h1>
<div class="meal">
<div><strong>{safe_recipe}</strong></div>
<div>{safe_day} &middot; {safe_meal}</div>
</div>
<div class="status">{_html_escape(msg)}</div>
<p style="margin-top:1rem;color:#555;font-size:14px">Meal plan status: {_html_escape(str(item_status))}.</p>
</body>
</html>
"""
return HTMLResponse(content=confirmation, status_code=200)
action_url = f"/api/meals/vote/{item_id}?token={_html_escape(token, quote=True)}"
existing_vote = db.query(MealPlanVote).filter(
@@ -216,10 +394,12 @@ def get_vote_page(
padding: 0 1rem; color: #111; background: #fff; line-height: 1.5; }}
h1 {{ font-size: 1.4rem; }}
.meal {{ padding: 1rem; border: 1px solid #444; border-radius: 6px; margin: 1rem 0; }}
button {{ font-size: 1rem; padding: .6rem 1.2rem; margin-right: .5rem;
.actions {{ display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }}
button {{ font-size: 1rem; padding: .6rem 1.2rem;
border: 2px solid #111; border-radius: 4px; cursor: pointer; }}
.approve {{ background: #0a6b2b; color: #fff; }}
.deny {{ background: #b00020; color: #fff; }}
.never {{ background: #5a0000; color: #fff; border-style: dashed; }}
#status {{ margin-top: 1rem; font-weight: bold; }}
</style>
</head>
@@ -230,29 +410,40 @@ def get_vote_page(
<div>{safe_day} &middot; {safe_meal}</div>
</div>
<form id="voteForm" method="post" action="{action_url}">
<button type="submit" name="vote" value="approve" class="approve" aria-label="Approve this meal">Approve</button>
<button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal">Deny</button>
<div class="actions">
<button type="submit" name="vote" value="approve" class="approve" aria-label="Approve this meal">Approve</button>
<button type="submit" name="vote" value="deny" class="deny" aria-label="Deny this meal for this week (will not reappear for 90 days)">Deny this week</button>
<button type="submit" name="vote" value="never_again" class="never" aria-label="Never suggest this recipe again">Never again</button>
</div>
</form>
<div id="status" role="status" aria-live="polite"></div>
<script>
document.getElementById('voteForm').addEventListener('submit', async function(e) {{
e.preventDefault();
var btn = e.submitter || document.activeElement;
var vote = btn && btn.value ? btn.value : 'approve';
var resp = await fetch(this.action, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ vote: vote }})
}});
var data = {{}};
try {{ data = await resp.json(); }} catch (_) {{}}
var s = document.getElementById('status');
if (resp.ok) {{
s.textContent = 'Recorded: ' + (data.item_status || vote);
}} else {{
s.textContent = 'Error: ' + (data.detail || resp.status);
}}
}});
document.getElementById('voteForm').addEventListener('submit', async function(e) {{
e.preventDefault();
var btn = e.submitter || document.activeElement;
var vote = btn && btn.value ? btn.value : 'approve';
var resp = await fetch(this.action, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ vote: vote }})
}});
var data = {{}};
try {{ data = await resp.json(); }} catch (_) {{}}
var s = document.getElementById('status');
if (resp.ok) {{
var msg = 'Recorded: ' + (data.item_status || vote);
if (vote !== 'approve' && data.promoted_to_permanent) {{
msg += '. This recipe will not be suggested again (permanently blocked).';
}} else if (vote === 'deny') {{
msg += '. Will not reappear for 90 days unless denied again.';
}} else if (vote === 'never_again') {{
msg += '. Permanently blocked.';
}}
s.textContent = msg;
}} else {{
s.textContent = 'Error: ' + (data.detail || resp.status);
}}
}});
</script>
</body>
</html>
@@ -270,8 +461,12 @@ def submit_vote(
"""Record a per-voter vote and apply the approval rule.
- Single-use enforcement lives in `approval_service.consume_token`.
- Approval rule: any deny -> item.denied; all-approve -> item.approved;
otherwise pending (waiting on remaining voters).
- Approval rule: any deny (any scope) -> item.denied; all-approve ->
item.approved; otherwise pending (waiting on remaining voters).
- Sprint 8: a `deny` (or `never_again`) vote also flows through
`_apply_denial` which may set `denial_expires_at` or promote the
recipe to a permanent `NeverSuggest` block (auto-escalation after
the 2nd denial in 90d).
"""
voter = approval_service.consume_token(db, token, item_id)
@@ -280,12 +475,29 @@ def submit_vote(
raise HTTPException(status_code=404, detail="Meal plan item not found")
vote_bool = submission.vote == "approve"
denial_scope: Optional[str] = None
promoted = False
if submission.vote == "never_again":
denial_scope = "never_again"
elif submission.vote == "deny":
denial_scope = "this_week"
db.add(MealPlanVote(
meal_plan_item_id=item_id,
family_member_id=voter.id,
vote=vote_bool,
denial_scope=denial_scope,
))
db.flush()
# Apply the denial (Sprint 8). For approve votes, this is a no-op
# other than the rule's effect on approval_status below.
if vote_bool is False and item.recipe_id is not None:
result = _apply_denial(db, item, scope=denial_scope or "this_week")
promoted = result["promoted_to_permanent"]
else:
# Approve path: keep existing approval-rule logic.
pass
# Approval rule: count electorate (all family members on this profile)
# vs votes recorded so far.
@@ -307,7 +519,12 @@ def submit_vote(
item.approval_status = MealPlanItemStatus.pending
db.commit()
return {"status": "recorded", "item_status": item.approval_status.value}
return {
"status": "recorded",
"item_status": item.approval_status.value,
"denial_scope": denial_scope,
"promoted_to_permanent": promoted,
}
@router.get("/items/{item_id}", response_model=MealPlanItemResponse)
@@ -346,6 +563,10 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
item.approval_status = MealPlanItemStatus.pending
item.denial_reason = None
item.denial_details = None
# Sprint 8: swapping to a new recipe clears any prior soft-deny
# window. The new recipe is a different recipe_id so the prior
# denial wouldn't apply anyway, but the new row starts fresh.
item.denial_expires_at = None
db.commit()
return {"message": "Meal swapped", "item": item}
@@ -363,14 +584,50 @@ def approve_meal_item(item_id: UUID, db: Session = Depends(get_db)):
@router.post("/items/{item_id}/deny")
def deny_meal_item(item_id: UUID, db: Session = Depends(get_db)):
"""Directly deny a meal plan item from the dashboard."""
def deny_meal_item(
item_id: UUID,
scope: str = Query(
"this_week",
pattern="^(this_week|never_again)$",
description=(
"Sprint 8: 'this_week' (default) sets denial_expires_at = now()+90d. "
"If a prior active soft denial exists for the same recipe, the "
"recipe is auto-promoted to a permanent NeverSuggest block. "
"'never_again' always writes a NeverSuggest row and clears "
"denial_expires_at (no decay)."
),
),
db: Session = Depends(get_db),
):
"""Directly deny a meal plan item from the dashboard.
Sprint 8: per the user's policy decision, two denials in the past
90 days (or any explicit "never_again") promote the recipe to a
permanent `NeverSuggest` block. The function returns the standard
`MealPlanItemResponse` plus a `promoted_to_permanent` boolean so
the frontend can show a clear toast ("Denied + won't suggest again").
"""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
item.approval_status = MealPlanItemStatus.denied
db.commit()
return {"message": "Meal denied", "item": item}
if item.recipe_id is None:
# Defensive: an item without a recipe can't be blocked by recipe.
item.approval_status = MealPlanItemStatus.denied
item.denial_expires_at = None
db.commit()
return {
"message": "Meal denied",
"item": item,
"promoted_to_permanent": False,
"scope": scope,
}
result = _apply_denial(db, item, scope=scope)
return {
"message": "Meal denied",
"item": result["item"],
"promoted_to_permanent": result["promoted_to_permanent"],
"scope": result["scope"],
}
@router.delete("/items/{item_id}")
+8
View File
@@ -229,6 +229,10 @@ class MealPlanItem(Base):
approval_status = Column(SQLEnum(MealPlanItemStatus, name="meal_plan_item_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), default=MealPlanItemStatus.pending)
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]))
denial_details = Column(Text)
# Sprint 8: when this denial stops being a "soft" signal. NULL means
# either an approve / a non-denial row, or a "Never again" denial
# (no decay; promoted to NeverSuggest).
denial_expires_at = Column(DateTime(timezone=True), nullable=True)
estimated_cost = Column(Numeric(10, 2))
score = Column(Float)
components = Column(JSONB)
@@ -254,6 +258,10 @@ class MealPlanVote(Base):
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="CASCADE"))
vote = Column(Boolean, nullable=False)
# Sprint 8: which deny-scope the voter chose. NULL for approve votes.
# "this_week" = soft denial, decays in 90d. "never_again" = hard block
# (a NeverSuggest row is also written for permanence).
denial_scope = Column(String(16), nullable=True)
voted_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
+7
View File
@@ -207,6 +207,8 @@ class MealPlanItemResponse(MealPlanItemBase):
approval_status: MealPlanItemStatus = MealPlanItemStatus.pending
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: when this denial decays. NULL = no decay (approve / never_again).
denial_expires_at: Optional[datetime] = None
used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None
components: Optional[Dict[str, float]] = None
@@ -249,6 +251,9 @@ class VoteRequest(BaseModel):
vote: bool
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: "this_week" (default) or "never_again". Only honored when
# vote=False; ignored for approve votes.
denial_scope: Optional[str] = Field(None, pattern="^(this_week|never_again)$")
class VoteResponse(BaseModel):
@@ -256,6 +261,8 @@ class VoteResponse(BaseModel):
meal_plan_item_id: UUID
family_member_id: UUID
vote: bool
# Sprint 8: which deny-scope the voter chose. NULL on approve votes.
denial_scope: Optional[str] = None
voted_at: Optional[datetime] = None
class Config:
+14 -3
View File
@@ -281,9 +281,20 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f'{ing_block}'
f'{instructions_block}'
f'{cost_block}'
f'<a href="{vote_url}" style="display:inline-block;margin-top:8px;padding:8px 16px;'
f'background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Vote on this meal</a>'
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;'
f'background:#16a34a;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Approve</a>'
f'<a href="{vote_url}&amp;scope=this_week" style="display:inline-block;padding:8px 14px;'
f'background:#dc2626;color:white;text-decoration:none;border-radius:6px;font-size:14px">'
f'Deny this week</a>'
f'<a href="{vote_url}&amp;scope=never_again" style="display:inline-block;padding:8px 14px;'
f'background:#7f1d1d;color:white;text-decoration:none;border-radius:6px;font-size:14px;'
f'border:1px dashed #fca5a5">'
f'Never again</a>'
f'</div>'
f'<div style="font-size:11px;color:#888;margin-top:4px">'
f'<a href="{vote_url}" style="color:#2563eb">Open vote page (all 3 options)</a></div>'
f'</div>'
)
+52 -4
View File
@@ -6,6 +6,7 @@ from decimal import Decimal
from typing import Dict, List, Optional, Set
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models import (
@@ -58,7 +59,19 @@ def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
def _load_blocklists(
db: Session, family_id: UUID
) -> tuple[Set[UUID], Set[UUID]]:
) -> tuple[Set[UUID], Set[UUID], Set[UUID]]:
"""Sprint 8: returns 3 sets of UUIDs.
- blocked_ingredients: ingredient-level NeverSuggest entries
- blocked_recipes: recipe-level NeverSuggest entries (permanent, no decay)
- soft_denied_recipes: meal_plan_item rows with approval_status='denied'
and denial_expires_at > now() (decaying in DENIAL_DECAY_DAYS; auto-
promoted to blocked_recipes on the 2nd denial in the window by the
/deny API path).
Both recipe sets are hard filters (user decision: "Hard filter. If it
is denied this week twice, it should be considered denied for good.").
"""
blocked_ingredients: Set[UUID] = set()
blocked_recipes: Set[UUID] = set()
for row in db.query(NeverSuggest).filter(NeverSuggest.family_profile_id == family_id).all():
@@ -66,7 +79,25 @@ def _load_blocklists(
blocked_ingredients.add(row.ingredient_id)
if row.recipe_id is not None:
blocked_recipes.add(row.recipe_id)
return blocked_ingredients, blocked_recipes
soft_denied_recipes: Set[UUID] = set()
rows = (
db.query(MealPlanItem.recipe_id)
.join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id)
.filter(
MealPlan.family_profile_id == family_id,
MealPlanItem.approval_status == MealPlanItemStatus.denied,
MealPlanItem.denial_expires_at.isnot(None),
MealPlanItem.denial_expires_at > func.now(),
MealPlanItem.recipe_id.isnot(None),
)
.distinct()
.all()
)
for (rid,) in rows:
soft_denied_recipes.add(rid)
return blocked_ingredients, blocked_recipes, soft_denied_recipes
def _load_pantry(db: Session, family_id: UUID) -> Set[UUID]:
@@ -147,9 +178,17 @@ def generate_meal_plan(
match_index = _load_match_index(db)
pantry_ids = _load_pantry(db, family_id)
blocked_ings, blocked_recipes = _load_blocklists(db, family_id)
blocked_ings, blocked_recipes, soft_denied_recipes = _load_blocklists(db, family_id)
last_cooked = _load_last_cooked(db, family_id)
# Sprint 8: union the soft-denied set with the permanent blocklist
# so the filter treats them identically. The `rejected[rid]` reason
# is "blocked_recipe" for both — operators reading the planner's
# `rejected_summary` see a single bucket. The soft set is also
# passed in separately so the diagnostic label could be split
# later if needed.
all_blocked_recipes = blocked_recipes | soft_denied_recipes
recipe_costs = {
r["id"]: compute_recipe_cost(
recipe_id=r["id"],
@@ -166,7 +205,7 @@ def generate_meal_plan(
recipe_ingredient_ids=recipe_ingredient_ids,
recipe_costs=recipe_costs,
blocked_ingredient_ids=blocked_ings,
blocked_recipe_ids=blocked_recipes,
blocked_recipe_ids=all_blocked_recipes,
last_cooked_at=last_cooked,
family_calorie_target=family.calorie_target,
config=effective_config,
@@ -216,6 +255,15 @@ def generate_meal_plan(
rejected_summary: Dict[str, int] = {}
for reason in filtered.rejected.values():
rejected_summary[reason] = rejected_summary.get(reason, 0) + 1
# Sprint 8: surface how many recipes are blocked specifically because
# of soft denials (vs. permanent NeverSuggest entries). Both are
# bucketed under "blocked_recipe" in the filter; this adds a
# "soft_denied_recipe" sub-bucket for diagnostics.
if soft_denied_recipes:
# Only count those that were actually candidates (in recipe_dicts).
soft_in_pool = sum(1 for r in recipe_dicts if r["id"] in soft_denied_recipes)
if soft_in_pool > 0:
rejected_summary["soft_denied_recipe"] = soft_in_pool
return GenerationResult(
meal_plan_id=plan.id,