Files
Meal-Planner/backend/alembic/versions/0001_initial_migration.py
T
adminandClaude Opus 4.7 8e89f793d5 feat: phase r1+r2 recovery + r3-0 swiftly api ingestion
R1 stabilization: pytest harness with transactional db fixture, smoke
+ alembic + auth + scrape + approval + swiftly tests, github actions
ci yaml. Bearer-token admin auth + signed-cookie session for family
ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns
202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL
fail-fast on empty.

R2 deferred-risk spikes: live lucky california fetch (R2-A), full
email+per-voter approval click round trip with single-use enforcement
(R2-B, console email backend, sendgrid stub).

R3-0 phase 3 redesign: replaced playwright html scraper with requests
based swiftly json api client. 17 categories, ~10k products per scrape,
upsert by (source, external_id). 401 surfaces actionable token-refresh
message via ScrapeLog.error_message.

Pre-existing defects fixed: shopping_list.py syntax error blocking app
import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid
kwarg, missing requests dep, calorie_target schema drift, every SQLEnum
needed values_callable, 0001 had empty downgrade(), seed had duplicate
ingredient rows.

Migrations added: 0003 grocery_item.description, 0004 family_profile.
calorie_target, 0005 grocery_item.external_id + source + composite index.

Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean,
frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:08:19 -07:00

302 lines
19 KiB
Python

"""Initial migration
Revision ID: 0001
Revises:
Create Date: 2026-05-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = '0001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
op.execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
op.create_enum = None
enum_types = [
"family_member_role_enum",
"meal_type_enum",
"meal_plan_status_enum",
"meal_plan_item_status_enum",
"approval_token_status_enum",
"denial_reason_enum",
"never_suggest_reason_enum",
"scrape_status_enum",
"email_status_enum"
]
for enum_name in enum_types:
op.execute(f"CREATE TYPE {enum_name} AS ENUM ('placeholder')")
op.execute(f"DROP TYPE {enum_name}")
op.execute("""CREATE TYPE family_member_role_enum AS ENUM ('adult', 'child')""")
op.execute("""CREATE TYPE meal_type_enum AS ENUM ('breakfast', 'lunch', 'dinner')""")
op.execute("""CREATE TYPE meal_plan_status_enum AS ENUM ('draft', 'pending_approval', 'approved', 'locked')""")
op.execute("""CREATE TYPE meal_plan_item_status_enum AS ENUM ('pending', 'approved', 'denied', 'swapped')""")
op.execute("""CREATE TYPE approval_token_status_enum AS ENUM ('active', 'used', 'expired')""")
op.execute("""CREATE TYPE denial_reason_enum AS ENUM ('too_expensive', 'boring', 'disliked_ingredient', 'cultural', 'other')""")
op.execute("""CREATE TYPE never_suggest_reason_enum AS ENUM ('allergy', 'dislike', 'tried_too_much', 'other')""")
op.execute("""CREATE TYPE scrape_status_enum AS ENUM ('started', 'success', 'failed')""")
op.execute("""CREATE TYPE email_status_enum AS ENUM ('sent', 'delivered', 'failed', 'bounced')""")
op.create_table('family_profile',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('household_size', sa.Integer(), nullable=False),
sa.Column('adult_count', sa.Integer(), nullable=False),
sa.Column('child_count', sa.Integer(), nullable=False),
sa.Column('dietary_notes', sa.Text(), nullable=True),
sa.Column('budget_per_meal', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('ingredient',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('name_lower', sa.String(length=200), nullable=False),
sa.Column('plural_name', sa.String(length=200), nullable=True),
sa.Column('aisle', sa.String(length=100), nullable=True),
sa.Column('typical_price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('unit', sa.String(length=50), nullable=True),
sa.Column('season_months', postgresql.ARRAY(sa.Integer()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name_lower')
)
op.create_table('family_member',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('family_profile_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=300), nullable=True),
sa.Column('role', postgresql.ENUM(name='family_member_role_enum', create_type=False), nullable=False),
sa.Column('likes_mushrooms', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_profile_id'], ['family_profile.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('family_profile_id', 'email', name='uq_family_member_profile_email')
)
op.create_table('recipe',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('family_profile_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image_url', sa.Text(), nullable=True),
sa.Column('image_source', sa.String(length=50), nullable=True),
sa.Column('prep_time_minutes', sa.Integer(), nullable=True),
sa.Column('cook_time_minutes', sa.Integer(), nullable=True),
sa.Column('servings', sa.Integer(), nullable=False),
sa.Column('servings_scaled', sa.Integer(), nullable=True),
sa.Column('cuisine_tags', postgresql.ARRAY(sa.String(length=50)), nullable=True),
sa.Column('dietary_tags', postgresql.ARRAY(sa.String(length=50)), nullable=True),
sa.Column('protein_type', sa.String(length=50), nullable=True),
sa.Column('spice_level', sa.Integer(), nullable=True),
sa.Column('ingredients', postgresql.JSONB(), nullable=False),
sa.Column('instructions', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('source_url', sa.Text(), nullable=True),
sa.Column('scraped_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_manually_added', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_profile_id'], ['family_profile.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('meal_plan',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('family_profile_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('week_start_date', sa.Date(), nullable=False),
sa.Column('status', postgresql.ENUM(name='meal_plan_status_enum', create_type=False), nullable=False),
sa.Column('approval_deadline', sa.DateTime(timezone=True), nullable=True),
sa.Column('total_estimated_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_profile_id'], ['family_profile.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('family_profile_id', 'week_start_date', name='uq_meal_plan_family_week')
)
op.create_table('home_pantry',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('family_profile_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('unit', sa.String(length=50), nullable=True),
sa.Column('expires_at', sa.Date(), nullable=True),
sa.Column('added_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_profile_id'], ['family_profile.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ingredient_id'], ['ingredient.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('family_profile_id', 'ingredient_id', name='uq_home_pantry_family_ingredient')
)
op.create_table('meal_plan_item',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('meal_plan_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('recipe_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('day_of_week', sa.Integer(), nullable=False),
sa.Column('meal_type', postgresql.ENUM(name='meal_type_enum', create_type=False), nullable=False),
sa.Column('approval_status', postgresql.ENUM(name='meal_plan_item_status_enum', create_type=False), nullable=True),
sa.Column('denial_reason', postgresql.ENUM(name='denial_reason_enum', create_type=False), nullable=True),
sa.Column('denial_details', sa.Text(), nullable=True),
sa.Column('estimated_cost', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('used_pantry_items', postgresql.ARRAY(postgresql.UUID(as_uuid=True)), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['meal_plan_id'], ['meal_plan.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('meal_plan_id', 'day_of_week', 'meal_type', name='uq_meal_plan_item_day_meal')
)
op.create_table('meal_plan_vote',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('meal_plan_item_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('family_member_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('vote', sa.Boolean(), nullable=False),
sa.Column('voted_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_member_id'], ['family_member.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['meal_plan_item_id'], ['meal_plan_item.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('meal_plan_item_id', 'family_member_id', name='uq_meal_plan_vote_item_member')
)
op.create_table('approval_token',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('meal_plan_item_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('family_member_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('token', sa.String(length=64), nullable=False),
sa.Column('status', postgresql.ENUM(name='approval_token_status_enum', create_type=False), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_member_id'], ['family_member.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['meal_plan_item_id'], ['meal_plan_item.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('meal_plan_item_id', 'family_member_id', name='uq_approval_token_item_member'),
sa.UniqueConstraint('token')
)
op.create_table('feedback',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('family_profile_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('family_member_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('meal_plan_item_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('rating', sa.Integer(), nullable=True),
sa.Column('never_suggest', sa.Boolean(), nullable=True),
sa.Column('denial_reason', postgresql.ENUM(name='denial_reason_enum', create_type=False), nullable=True),
sa.Column('feedback_text', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_profile_id'], ['family_profile.id'], ),
sa.ForeignKeyConstraint(['family_member_id'], ['family_member.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['meal_plan_item_id'], ['meal_plan_item.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('never_suggest',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('family_profile_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('recipe_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('reason', postgresql.ENUM(name='never_suggest_reason_enum', create_type=False), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['family_profile_id'], ['family_profile.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ingredient_id'], ['ingredient.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['recipe_id'], ['recipe.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('grocery_item',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('name', sa.String(length=300), nullable=False),
sa.Column('brand', sa.String(length=200), nullable=True),
sa.Column('current_price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('regular_price', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('unit', sa.String(length=50), nullable=True),
sa.Column('aisle', sa.String(length=100), nullable=True),
sa.Column('image_url', sa.Text(), nullable=True),
sa.Column('product_url', sa.Text(), nullable=True),
sa.Column('is_on_sale', sa.Boolean(), nullable=True),
sa.Column('sale_start_date', sa.Date(), nullable=True),
sa.Column('sale_end_date', sa.Date(), nullable=True),
sa.Column('in_season', sa.Boolean(), nullable=True),
sa.Column('scraped_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('scraped_url', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['ingredient_id'], ['ingredient.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('scrape_log',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('scrape_type', sa.String(length=50), nullable=False),
sa.Column('status', postgresql.ENUM(name='scrape_status_enum', create_type=False), nullable=False),
sa.Column('items_scraped', sa.Integer(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('email_log',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('recipient_email', sa.String(length=300), nullable=False),
sa.Column('recipient_name', sa.String(length=200), nullable=True),
sa.Column('template', sa.String(length=100), nullable=False),
sa.Column('meal_plan_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('meal_plan_item_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('sendgrid_message_id', sa.String(length=100), nullable=True),
sa.Column('status', postgresql.ENUM(name='email_status_enum', create_type=False), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['meal_plan_id'], ['meal_plan.id'], ),
sa.ForeignKeyConstraint(['meal_plan_item_id'], ['meal_plan_item.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_family_member_profile', 'family_member', ['family_profile_id'])
op.create_index('idx_recipe_family_profile', 'recipe', ['family_profile_id'])
op.create_index('idx_meal_plan_family_profile', 'meal_plan', ['family_profile_id'])
op.create_index('idx_meal_plan_item_meal_plan', 'meal_plan_item', ['meal_plan_id'])
op.create_index('idx_meal_plan_item_recipe', 'meal_plan_item', ['recipe_id'])
op.create_index('idx_meal_plan_vote_item', 'meal_plan_vote', ['meal_plan_item_id'])
op.create_index('idx_meal_plan_vote_member', 'meal_plan_vote', ['family_member_id'])
op.create_index('idx_approval_token_item', 'approval_token', ['meal_plan_item_id'])
op.create_index('idx_approval_token_member', 'approval_token', ['family_member_id'])
op.create_index('idx_home_pantry_family_profile', 'home_pantry', ['family_profile_id'])
op.create_index('idx_home_pantry_ingredient', 'home_pantry', ['ingredient_id'])
op.create_index('idx_feedback_family_profile', 'feedback', ['family_profile_id'])
op.create_index('idx_feedback_member', 'feedback', ['family_member_id'])
op.create_index('idx_feedback_meal_plan_item', 'feedback', ['meal_plan_item_id'])
op.create_index('idx_never_suggest_family_profile', 'never_suggest', ['family_profile_id'])
op.create_index('idx_grocery_item_ingredient', 'grocery_item', ['ingredient_id'])
op.create_index('idx_grocery_item_is_on_sale', 'grocery_item', [sa.text('is_on_sale')], postgresql_where=sa.text('is_on_sale = TRUE'))
op.execute("CREATE INDEX idx_ingredient_name_lower_fts ON ingredient USING gin(to_tsvector('english', name_lower))")
op.execute("CREATE INDEX idx_recipe_name_fts ON recipe USING gin(to_tsvector('english', name))")
op.create_check_constraint('ck_household_size', 'family_profile', 'household_size > 0')
op.create_check_constraint('ck_adult_count', 'family_profile', 'adult_count > 0')
op.create_check_constraint('ck_household_sum', 'family_profile', 'adult_count + child_count = household_size')
op.create_check_constraint('ck_day_of_week', 'meal_plan_item', 'day_of_week BETWEEN 1 AND 7')
op.create_check_constraint('ck_rating', 'feedback', 'rating BETWEEN 1 AND 5')
def downgrade() -> None:
op.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'alembic_version') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
FOR r IN (SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public') LOOP
EXECUTE 'DROP TYPE IF EXISTS public.' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
"""
)