Public Access
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:
+294
-37
@@ -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} · {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} · {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}")
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}&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}&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}&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>'
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user