Files
Meal-Planner/backend/app/models/__init__.py
T
MealPlanner efd1fc695f 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
2026-06-05 10:24:35 -07:00

460 lines
19 KiB
Python

from sqlalchemy import (
Column, String, Integer, Numeric, Float, Text, Boolean, Date, DateTime,
ForeignKey, CheckConstraint, UniqueConstraint, Enum as SQLEnum
)
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import uuid
import enum
class DayOfWeek(enum.Enum):
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
SUNDAY = 7
class MealType(enum.Enum):
BREAKFAST = "breakfast"
LUNCH = "lunch"
DINNER = "dinner"
class MealPlanStatus(enum.Enum):
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
LOCKED = "locked"
class MealPlanItemStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
denied = "denied"
swapped = "swapped"
class ApprovalTokenStatus(enum.Enum):
ACTIVE = "active"
USED = "used"
EXPIRED = "expired"
class FamilyMemberRole(enum.Enum):
ADULT = "adult"
CHILD = "child"
class DenialReason(enum.Enum):
TOO_EXPENSIVE = "too_expensive"
BORING = "boring"
DISLIKED_INGREDIENT = "disliked_ingredient"
CULTURAL = "cultural"
OTHER = "other"
class NeverSuggestReason(enum.Enum):
ALLERGY = "allergy"
DISLIKE = "dislike"
TRIED_TOO_MUCH = "tried_too_much"
OTHER = "other"
class ScrapeStatus(enum.Enum):
STARTED = "started"
SUCCESS = "success"
FAILED = "failed"
class EmailStatus(enum.Enum):
SENT = "sent"
DELIVERED = "delivered"
FAILED = "failed"
BOUNCED = "bounced"
class IngredientMatchSource(enum.Enum):
AUTO = "auto"
AUTO_LLM = "auto_llm"
MANUAL = "manual"
class FamilyProfile(Base):
__tablename__ = "family_profile"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
household_size = Column(Integer, nullable=False)
adult_count = Column(Integer, nullable=False)
child_count = Column(Integer, nullable=False)
dietary_notes = Column(Text)
budget_per_meal = Column(Numeric(10, 2), default=50.00)
calorie_target = Column(Integer)
pending_approval_policy = Column(String(10), nullable=False, server_default="approve")
planner_config = Column(JSONB, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
CheckConstraint("adult_count + child_count = household_size"),
CheckConstraint("household_size > 0"),
CheckConstraint("adult_count > 0"),
)
members = relationship("FamilyMember", back_populates="family_profile", cascade="all, delete-orphan")
recipes = relationship("Recipe", back_populates="family_profile")
meal_plans = relationship("MealPlan", back_populates="family_profile")
pantry_items = relationship("HomePantry", back_populates="family_profile", cascade="all, delete-orphan")
feedbacks = relationship("Feedback", back_populates="family_profile")
never_suggests = relationship("NeverSuggest", back_populates="family_profile", cascade="all, delete-orphan")
weekly_runs = relationship(
"WeeklyRun", back_populates="family_profile", cascade="all, delete-orphan"
)
class FamilyMember(Base):
__tablename__ = "family_member"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
name = Column(String(100), nullable=False)
email = Column(String(300))
role = Column(SQLEnum(FamilyMemberRole, name="family_member_role_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
likes_mushrooms = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("family_profile_id", "email"),
)
family_profile = relationship("FamilyProfile", back_populates="members")
votes = relationship("MealPlanVote", back_populates="family_member", cascade="all, delete-orphan")
feedbacks = relationship("Feedback", back_populates="family_member")
class Ingredient(Base):
__tablename__ = "ingredient"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
name_lower = Column(String(200), nullable=False, unique=True)
plural_name = Column(String(200))
aisle = Column(String(100))
typical_price = Column(Numeric(10, 2))
unit = Column(String(50))
season_months = Column(ARRAY(Integer))
aliases = Column(ARRAY(Text), nullable=False, server_default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now())
pantry_items = relationship("HomePantry", back_populates="ingredient")
never_suggests = relationship("NeverSuggest", back_populates="ingredient")
grocery_item_links = relationship("GroceryItem", back_populates="ingredient")
matches = relationship("IngredientGroceryMatch", back_populates="ingredient", cascade="all, delete-orphan")
class Recipe(Base):
__tablename__ = "recipe"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
name = Column(String(300), nullable=False)
description = Column(Text)
image_url = Column(Text)
image_source = Column(String(50))
prep_time_minutes = Column(Integer)
cook_time_minutes = Column(Integer)
servings = Column(Integer, nullable=False)
servings_scaled = Column(Integer)
cuisine_tags = Column(ARRAY(String(50)))
dietary_tags = Column(ARRAY(String(50)))
protein_type = Column(String(50))
spice_level = Column(Integer)
calories_per_serving = Column(Integer)
ingredients = Column(JSONB, nullable=False)
side_dishes = Column(JSONB, default=list)
instructions = Column(ARRAY(Text), nullable=False)
source_url = Column(Text)
scraped_at = Column(DateTime(timezone=True))
is_manually_added = Column(Boolean, default=False)
external_source = Column(String(50))
external_id = Column(String(100))
discovery_reason = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
family_profile = relationship("FamilyProfile", back_populates="recipes")
meal_plan_items = relationship("MealPlanItem", back_populates="recipe")
@property
def total_time_minutes(self):
return (self.prep_time_minutes or 0) + (self.cook_time_minutes or 0)
class MealPlan(Base):
__tablename__ = "meal_plan"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
week_start_date = Column(Date, nullable=False)
status = Column(SQLEnum(MealPlanStatus, name="meal_plan_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False, default=MealPlanStatus.DRAFT)
approval_deadline = Column(DateTime(timezone=True))
total_estimated_cost = Column(Numeric(10, 2))
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("family_profile_id", "week_start_date"),
)
family_profile = relationship("FamilyProfile", back_populates="meal_plans")
items = relationship("MealPlanItem", back_populates="meal_plan", cascade="all, delete-orphan")
class MealPlanItem(Base):
__tablename__ = "meal_plan_item"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id", ondelete="CASCADE"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
day_of_week = Column(Integer, nullable=False)
meal_type = Column(SQLEnum(MealType, name="meal_type_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
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)
used_pantry_items = Column(ARRAY(UUID(as_uuid=True)))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_id", "day_of_week", "meal_type"),
CheckConstraint("day_of_week BETWEEN 1 AND 7"),
)
meal_plan = relationship("MealPlan", back_populates="items")
recipe = relationship("Recipe", back_populates="meal_plan_items")
feedback = relationship("Feedback", back_populates="meal_plan_item", uselist=False)
votes = relationship("MealPlanVote", back_populates="meal_plan_item", cascade="all, delete-orphan")
class MealPlanVote(Base):
__tablename__ = "meal_plan_vote"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
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__ = (
UniqueConstraint("meal_plan_item_id", "family_member_id"),
)
meal_plan_item = relationship("MealPlanItem", back_populates="votes")
family_member = relationship("FamilyMember", back_populates="votes")
class ApprovalToken(Base):
__tablename__ = "approval_token"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
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"))
token = Column(String(64), nullable=False, unique=True)
status = Column(SQLEnum(ApprovalTokenStatus, name="approval_token_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), default=ApprovalTokenStatus.ACTIVE)
expires_at = Column(DateTime(timezone=True), nullable=False)
used_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("meal_plan_item_id", "family_member_id"),
)
meal_plan_item = relationship("MealPlanItem")
family_member = relationship("FamilyMember")
class HomePantry(Base):
__tablename__ = "home_pantry"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
quantity = Column(Numeric(10, 2))
unit = Column(String(50))
expires_at = Column(Date)
added_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint("family_profile_id", "ingredient_id"),
)
family_profile = relationship("FamilyProfile", back_populates="pantry_items")
ingredient = relationship("Ingredient", back_populates="pantry_items")
class Feedback(Base):
__tablename__ = "feedback"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
family_member_id = Column(UUID(as_uuid=True), ForeignKey("family_member.id", ondelete="SET NULL"))
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
rating = Column(Integer)
never_suggest = Column(Boolean, default=False)
denial_reason = Column(SQLEnum(DenialReason, name="denial_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]))
feedback_text = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
CheckConstraint("rating BETWEEN 1 AND 5"),
)
family_profile = relationship("FamilyProfile", back_populates="feedbacks")
family_member = relationship("FamilyMember", back_populates="feedbacks")
meal_plan_item = relationship("MealPlanItem", back_populates="feedback")
class NeverSuggest(Base):
__tablename__ = "never_suggest"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"))
recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
reason = Column(SQLEnum(NeverSuggestReason, name="never_suggest_reason_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]))
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
family_profile = relationship("FamilyProfile", back_populates="never_suggests")
ingredient = relationship("Ingredient", back_populates="never_suggests")
class GroceryItem(Base):
__tablename__ = "grocery_item"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="SET NULL"))
name = Column(String(300), nullable=False)
brand = Column(String(200))
current_price = Column(Numeric(10, 2))
regular_price = Column(Numeric(10, 2))
unit = Column(String(50))
aisle = Column(String(100))
image_url = Column(Text)
product_url = Column(Text)
description = Column(Text, nullable=True)
is_on_sale = Column(Boolean, default=False)
sale_start_date = Column(Date)
sale_end_date = Column(Date)
in_season = Column(Boolean, default=False)
scraped_at = Column(DateTime(timezone=True), server_default=func.now())
scraped_url = Column(Text)
# R3-0: Swiftly API idempotency key. (source, external_id) → upsert.
external_id = Column(String(100), nullable=True, index=True)
source = Column(String(50), nullable=True)
ingredient = relationship("Ingredient", back_populates="grocery_item_links")
class ScrapeLog(Base):
__tablename__ = "scrape_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source = Column(String(50), nullable=False)
scrape_type = Column(String(50), nullable=False)
status = Column(SQLEnum(ScrapeStatus, name="scrape_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
items_scraped = Column(Integer, default=0)
error_message = Column(Text)
started_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True))
duration_seconds = Column(Integer)
class EmailLog(Base):
__tablename__ = "email_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
recipient_email = Column(String(300), nullable=False)
recipient_name = Column(String(200))
template = Column(String(100), nullable=False)
meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id"))
meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id"))
sendgrid_message_id = Column(String(100))
status = Column(SQLEnum(EmailStatus, name="email_status_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
error_message = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
delivered_at = Column(DateTime(timezone=True))
meal_plan = relationship("MealPlan")
meal_plan_item = relationship("MealPlanItem")
class IngredientGroceryMatch(Base):
__tablename__ = "ingredient_grocery_match"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id", ondelete="CASCADE"), nullable=False)
grocery_item_id = Column(UUID(as_uuid=True), ForeignKey("grocery_item.id", ondelete="CASCADE"), nullable=False)
confidence = Column(Numeric(4, 3), nullable=False)
source = Column(SQLEnum(IngredientMatchSource, name="ingredient_match_source_enum", create_type=False, values_callable=lambda obj: [e.value for e in obj]), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("ingredient_id", "grocery_item_id", name="uq_ingredient_grocery_match_pair"),
)
ingredient = relationship("Ingredient", back_populates="matches")
grocery_item = relationship("GroceryItem")
class WeeklyRun(Base):
__tablename__ = "weekly_run"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
family_id = Column(
UUID(as_uuid=True),
ForeignKey("family_profile.id", ondelete="CASCADE"),
nullable=False,
)
week_start_date = Column(Date, nullable=False)
status = Column(String(20), nullable=False, default="pending")
scraped_at = Column(DateTime(timezone=True))
generated_at = Column(DateTime(timezone=True))
emailed_at = Column(DateTime(timezone=True))
reminded_at = Column(DateTime(timezone=True))
deadline_passed_at = Column(DateTime(timezone=True))
finalized_at = Column(DateTime(timezone=True))
feedback_analysis = Column(JSONB, nullable=True)
used_stale_data = Column(Boolean, nullable=False, default=False)
error_step = Column(String(50))
error_message = Column(Text)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint(
"family_id", "week_start_date", name="uq_weekly_run_family_week"
),
)
family_profile = relationship("FamilyProfile", back_populates="weekly_runs")