"""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")