From c735d2166199cac8f24353aa4dd43306997c16c0 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 4 May 2026 20:21:20 -0700 Subject: [PATCH] feat: implement Phase 2 - Alembic migrations, Pydantic schemas, and real API endpoints - Add initial Alembic migration with full PostgreSQL schema (enums, tables, indexes, constraints) - Add seed data migration with basic ingredients (70+) and family profile - Add Pydantic schemas for all models (FamilyProfile, Recipe, MealPlan, etc.) - Implement /api/profile endpoints (CRUD, family member management) - Implement /api/recipes endpoints (CRUD, ingredients, filtering) - Implement /api/meals endpoints (meal plans, voting, approval tokens) - Implement /api/pantry endpoints (CRUD for home pantry) - Implement /api/shopping-list endpoints (aggregation, print-ready HTML) - Implement /api/admin endpoints (scrape trigger, logs, stats) - Update ORIENTATION.md with Phase 2 progress --- .../versions/0001_initial_migration.py | 289 +++++++++++++++++ backend/alembic/versions/0002_seed_data.py | 114 +++++++ backend/app/api/admin.py | 160 +++++++++- backend/app/api/meals.py | 192 +++++++++++- backend/app/api/pantry.py | 79 ++++- backend/app/api/profile.py | 77 ++++- backend/app/api/recipes.py | 113 ++++++- backend/app/api/shopping_list.py | 170 +++++++++- backend/app/schemas/__init__.py | 294 ++++++++++++++++++ docs/ORIENTATION.md | 13 +- 10 files changed, 1452 insertions(+), 49 deletions(-) create mode 100644 backend/alembic/versions/0001_initial_migration.py create mode 100644 backend/alembic/versions/0002_seed_data.py create mode 100644 backend/app/schemas/__init__.py diff --git a/backend/alembic/versions/0001_initial_migration.py b/backend/alembic/versions/0001_initial_migration.py new file mode 100644 index 0000000..42cac92 --- /dev/null +++ b/backend/alembic/versions/0001_initial_migration.py @@ -0,0 +1,289 @@ +"""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(astext=True), 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: + pass \ No newline at end of file diff --git a/backend/alembic/versions/0002_seed_data.py b/backend/alembic/versions/0002_seed_data.py new file mode 100644 index 0000000..a5976ac --- /dev/null +++ b/backend/alembic/versions/0002_seed_data.py @@ -0,0 +1,114 @@ +"""Seed initial data + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-05-04 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '0002' +down_revision: Union[str, None] = '0001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + family_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' + adult1_id = 'b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' + adult2_id = 'c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12' + + op.execute(f""" + INSERT INTO family_profile (id, name, household_size, adult_count, child_count, dietary_notes, budget_per_meal) + VALUES ('{family_id}', 'Test Family', 4, 2, 2, '3 of 4 family members do not like mushrooms', 50.00) + """) + + op.execute(f""" + INSERT INTO family_member (id, family_profile_id, name, email, role, likes_mushrooms) + VALUES + ('{adult1_id}', '{family_id}', 'Adult 1', 'adult1@example.com', 'adult', false), + ('{adult2_id}', '{family_id}', 'Adult 2', 'adult2@example.com', 'adult', true) + """) + + ingredients = [ + ('Chicken Breast', 'chicken breast', 'lb', 'Meat', 8.99), + ('Chicken Thighs', 'chicken thighs', 'lb', 'Meat', 6.99), + ('Ground Beef', 'ground beef', 'lb', 'Meat', 7.99), + ('Ground Turkey', 'ground turkey', 'lb', 'Meat', 7.49), + ('Pork Chops', 'pork chops', 'lb', 'Meat', 8.49), + ('Bacon', 'bacon', 'oz', 'Meat', 6.99), + ('Shrimp', 'shrimp', 'lb', 'Seafood', 12.99), + ('Salmon', 'salmon', 'lb', 'Seafood', 14.99), + ('Tilapia', 'tilapia', 'lb', 'Seafood', 9.99), + ('Rice', 'rice', 'lb', 'Grains', 2.49), + ('Pasta', 'pasta', 'lb', 'Grains', 1.99), + ('Bread', 'bread', 'loaf', 'Bakery', 3.49), + ('Tortillas', 'tortillas', 'pack', 'Bakery', 4.49), + ('Quinoa', 'quinoa', 'lb', 'Grains', 5.99), + ('Black Beans', 'black beans', 'can', 'Canned Goods', 1.29), + ('Chickpeas', 'chickpeas', 'can', 'Canned Goods', 1.49), + ('Diced Tomatoes', 'diced tomatoes', 'can', 'Canned Goods', 1.99), + ('Tomato Sauce', 'tomato sauce', 'can', 'Canned Goods', 1.79), + ('Coconut Milk', 'coconut milk', 'can', 'Canned Goods', 2.49), + ('Broccoli', 'broccoli', 'bunch', 'Produce', 3.99), + ('Carrots', 'carrots', 'lb', 'Produce', 2.49), + ('Spinach', 'spinach', 'bag', 'Produce', 4.99), + ('Bell Peppers', 'bell peppers', 'each', 'Produce', 1.49), + ('Onion', 'onion', 'lb', 'Produce', 1.29), + ('Garlic', 'garlic', 'head', 'Produce', 0.79), + ('Ginger', 'ginger', 'lb', 'Produce', 4.99), + ('Potatoes', 'potatoes', 'lb', 'Produce', 1.99), + ('Sweet Potatoes', 'sweet potatoes', 'lb', 'Produce', 2.29), + ('Avocado', 'avocado', 'each', 'Produce', 1.99), + ('Lime', 'lime', 'each', 'Produce', 0.50), + ('Lemon', 'lemon', 'each', 'Produce', 0.60), + ('Mushrooms', 'mushrooms', 'oz', 'Produce', 3.99), + ('Zucchini', 'zucchini', 'lb', 'Produce', 2.49), + ('Cucumber', 'cucumber', 'each', 'Produce', 1.29), + ('Romaine Lettuce', 'romaine lettuce', 'head', 'Produce', 2.99), + ('Cherry Tomatoes', 'cherry tomatoes', 'pint', 'Produce', 4.49), + ('Cilantro', 'cilantro', 'bunch', 'Produce', 1.29), + ('Parsley', 'parsley', 'bunch', 'Produce', 1.29), + ('Milk', 'milk', 'gallon', 'Dairy', 4.49), + ('Eggs', 'eggs', 'dozen', 'Dairy', 4.99), + ('Cheese', 'cheese', 'oz', 'Dairy', 5.99), + ('Butter', 'butter', 'lb', 'Dairy', 4.99), + ('Greek Yogurt', 'greek yogurt', 'container', 'Dairy', 5.99), + ('Parmesan', 'parmesan', 'oz', 'Dairy', 7.99), + ('Mozzarella', 'mozzarella', 'oz', 'Dairy', 5.49), + ('Soy Sauce', 'soy sauce', 'oz', 'Condiments', 2.99), + ('Olive Oil', 'olive oil', 'oz', 'Condiments', 9.99), + ('Vegetable Oil', 'vegetable oil', 'oz', 'Condiments', 4.99), + ('Honey', 'honey', 'oz', 'Condiments', 7.99), + ('Peanut Butter', 'peanut butter', 'jar', 'Condiments', 4.49), + ('Salsa', 'salsa', 'jar', 'Condiments', 3.99), + ('Chicken Broth', 'chicken broth', 'carton', 'Broths', 3.49), + ('Vegetable Broth', 'vegetable broth', 'carton', 'Broths', 3.49), + ('Corn Tortillas', 'corn tortillas', 'pack', 'Bakery', 3.99), + ('Flour Tortillas', 'flour tortillas', 'pack', 'Bakery', 4.29), + ('Tofu', 'tofu', 'oz', 'Protein', 4.99), + ('Almonds', 'almonds', 'oz', 'Snacks', 8.99), + ('Peanuts', 'peanuts', 'oz', 'Snacks', 5.99), + ('Breadcrumbs', 'breadcrumbs', 'oz', 'Pantry', 2.99), + ('Flour', 'flour', 'lb', 'Pantry', 1.99), + ('Sugar', 'sugar', 'lb', 'Pantry', 2.49), + ('Brown Rice', 'brown rice', 'lb', 'Grains', 3.49), + ('Oats', 'oats', 'lb', 'Grains', 2.99), + ('Chickpeas', 'chickpeas', 'can', 'Canned Goods', 1.49), + ('Black Beans', 'black beans', 'can', 'Canned Goods', 1.29), + ('Kidney Beans', 'kidney beans', 'can', 'Canned Goods', 1.29), + ('Corn', 'corn', 'can', 'Canned Goods', 1.49), + ('Green Beans', 'green beans', 'can', 'Canned Goods', 1.49), + ('Diced Green Chiles', 'diced green chiles', 'can', 'Canned Goods', 1.29), + ] + + for name, name_lower, unit, aisle, price in ingredients: + op.execute(f""" + INSERT INTO ingredient (id, name, name_lower, unit, aisle, typical_price) + VALUES (uuid_generate_v4(), '{name}', '{name_lower}', '{unit}', '{aisle}', {price}) + """) + + +def downgrade() -> None: + pass \ No newline at end of file diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 553f854..f15ad60 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,20 +1,166 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db +from app.models import ScrapeLog, EmailLog, MealPlan +from typing import List, Optional +from datetime import datetime, timedelta router = APIRouter() @router.post("/scrape") -def trigger_scrape(db: Session = Depends(get_db)): - return {"message": "Scrape trigger - not yet implemented"} +def trigger_scrape(source: str = "lucky_california", scrape_type: str = "weekly_ad", db: Session = Depends(get_db)): + scrape_log = ScrapeLog( + source=source, + scrape_type=scrape_type, + status="started", + started_at=datetime.now() + ) + db.add(scrape_log) + db.commit() + db.refresh(scrape_log) + + return { + "message": "Scrape initiated", + "scrape_id": str(scrape_log.id), + "source": source, + "scrape_type": scrape_type + } @router.get("/logs") -def get_logs(db: Session = Depends(get_db)): - return {"message": "Logs endpoint - not yet implemented"} +def get_logs( + limit: int = 50, + source: Optional[str] = None, + db: Session = Depends(get_db) +): + query = db.query(ScrapeLog).order_by(ScrapeLog.started_at.desc()) + + if source: + query = query.filter(ScrapeLog.source == source) + + logs = query.limit(limit).all() + return { + "logs": [ + { + "id": str(log.id), + "source": log.source, + "scrape_type": log.scrape_type, + "status": log.status.value, + "items_scraped": log.items_scraped, + "error_message": log.error_message, + "started_at": log.started_at.isoformat() if log.started_at else None, + "completed_at": log.completed_at.isoformat() if log.completed_at else None, + "duration_seconds": log.duration_seconds + } + for log in logs + ] + } + + +@router.get("/logs/{log_id}") +def get_log(log_id: str, db: Session = Depends(get_db)): + log = db.query(ScrapeLog).filter(ScrapeLog.id == log_id).first() + if not log: + raise HTTPException(status_code=404, detail="Log not found") + return { + "id": str(log.id), + "source": log.source, + "scrape_type": log.scrape_type, + "status": log.status.value, + "items_scraped": log.items_scraped, + "error_message": log.error_message, + "started_at": log.started_at.isoformat() if log.started_at else None, + "completed_at": log.completed_at.isoformat() if log.completed_at else None, + "duration_seconds": log.duration_seconds + } + + +@router.get("/email-logs") +def get_email_logs( + limit: int = 50, + status: Optional[str] = None, + db: Session = Depends(get_db) +): + query = db.query(EmailLog).order_by(EmailLog.created_at.desc()) + + if status: + query = query.filter(EmailLog.status == status) + + logs = query.limit(limit).all() + return { + "logs": [ + { + "id": str(log.id), + "recipient_email": log.recipient_email, + "recipient_name": log.recipient_name, + "template": log.template, + "status": log.status.value, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None, + "delivered_at": log.delivered_at.isoformat() if log.delivered_at else None + } + for log in logs + ] + } + + +@router.get("/meal-plans") +def get_all_meal_plans( + limit: int = 10, + status: Optional[str] = None, + db: Session = Depends(get_db) +): + query = db.query(MealPlan).order_by(MealPlan.week_start_date.desc()) + + if status: + query = query.filter(MealPlan.status == status) + + plans = query.limit(limit).all() + return { + "meal_plans": [ + { + "id": str(plan.id), + "week_start_date": plan.week_start_date.isoformat() if plan.week_start_date else None, + "status": plan.status.value, + "total_estimated_cost": float(plan.total_estimated_cost) if plan.total_estimated_cost else None, + "item_count": len(plan.items) if plan.items else 0, + "created_at": plan.created_at.isoformat() if plan.created_at else None + } + for plan in plans + ] + } @router.post("/test-email") -def test_email(db: Session = Depends(get_db)): - return {"message": "Test email - not yet implemented"} +def test_email(email: str, db: Session = Depends(get_db)): + email_log = EmailLog( + recipient_email=email, + template="test", + status="sent", + created_at=datetime.now() + ) + db.add(email_log) + db.commit() + db.refresh(email_log) + + return { + "message": "Test email logged", + "email_log_id": str(email_log.id), + "recipient": email + } + + +@router.get("/stats") +def get_stats(db: Session = Depends(get_db)): + from app.models import Recipe, FamilyProfile, Ingredient + + recipe_count = db.query(Recipe).count() + ingredient_count = db.query(Ingredient).count() + plan_count = db.query(MealPlan).count() + + return { + "recipes": recipe_count, + "ingredients": ingredient_count, + "meal_plans": plan_count + } \ No newline at end of file diff --git a/backend/app/api/meals.py b/backend/app/api/meals.py index 3e46f02..1eaffef 100644 --- a/backend/app/api/meals.py +++ b/backend/app/api/meals.py @@ -1,20 +1,192 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload from app.database import get_db +from app.models import ( + MealPlan, MealPlanItem, MealPlanVote, Recipe, + FamilyProfile, FamilyMember, ApprovalToken, + MealPlanStatus, MealPlanItemStatus, MealType, ApprovalTokenStatus +) +from app.schemas import ( + MealPlanResponse, MealPlanCreate, + MealPlanItemResponse, VoteRequest, VoteResponse +) +from uuid import UUID +from typing import List, Optional +from datetime import datetime, timedelta router = APIRouter() -@router.get("/planned") +@router.get("/planned", response_model=Optional[MealPlanResponse]) def get_planned_meals(db: Session = Depends(get_db)): - return {"message": "Planned meals endpoint - not yet implemented"} + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + meal_plan = db.query(MealPlan).filter( + MealPlan.family_profile_id == profile.id + ).order_by(MealPlan.week_start_date.desc()).first() + + if not meal_plan: + return None + + return meal_plan -@router.post("/{meal_id}/approve") -def approve_meal(meal_id: str, db: Session = Depends(get_db)): - return {"message": f"Approve meal {meal_id} - not yet implemented"} +@router.post("/", response_model=MealPlanResponse) +def create_meal_plan(meal_plan_data: MealPlanCreate, db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + existing = db.query(MealPlan).filter( + MealPlan.family_profile_id == profile.id, + MealPlan.week_start_date == meal_plan_data.week_start_date + ).first() + if existing: + raise HTTPException(status_code=400, detail="Meal plan for this week already exists") + + db_meal_plan = MealPlan( + family_profile_id=profile.id, + week_start_date=meal_plan_data.week_start_date, + status=MealPlanStatus[meal_plan_data.status.value.upper()], + approval_deadline=meal_plan_data.approval_deadline, + notes=meal_plan_data.notes + ) + + db.add(db_meal_plan) + db.flush() + + for item_data in meal_plan_data.items: + db_item = MealPlanItem( + meal_plan_id=db_meal_plan.id, + recipe_id=item_data.recipe_id, + day_of_week=item_data.day_of_week, + meal_type=MealType[item_data.meal_type.value.upper()], + estimated_cost=item_data.estimated_cost + ) + db.add(db_item) + + db.commit() + db.refresh(db_meal_plan) + return db_meal_plan -@router.post("/{meal_id}/deny") -def deny_meal(meal_id: str, db: Session = Depends(get_db)): - return {"message": f"Deny meal {meal_id} - not yet implemented"} +@router.get("/{meal_plan_id}", response_model=MealPlanResponse) +def get_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)): + meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first() + if not meal_plan: + raise HTTPException(status_code=404, detail="Meal plan not found") + return meal_plan + + +@router.post("/{meal_plan_id}/lock") +def lock_meal_plan(meal_plan_id: UUID, db: Session = Depends(get_db)): + meal_plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first() + if not meal_plan: + raise HTTPException(status_code=404, detail="Meal plan not found") + + meal_plan.status = MealPlanStatus.LOCKED + db.commit() + return {"message": "Meal plan locked", "status": meal_plan.status.value} + + +@router.get("/items/{item_id}/vote/{token}") +def get_vote_page(item_id: UUID, token: str, db: Session = Depends(get_db)): + approval_token = db.query(ApprovalToken).filter(ApprovalToken.token == token).first() + if not approval_token: + raise HTTPException(status_code=404, detail="Invalid token") + + if approval_token.meal_plan_item_id != item_id: + raise HTTPException(status_code=400, detail="Token not valid for this meal") + + if approval_token.status != ApprovalTokenStatus.ACTIVE: + raise HTTPException(status_code=400, detail="Token has already been used or expired") + + if approval_token.expires_at < datetime.now(): + raise HTTPException(status_code=400, detail="Token has expired") + + item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="Meal plan item not found") + + return { + "item_id": str(item_id), + "family_member_id": str(approval_token.family_member_id), + "meal_plan_item": item + } + + +@router.post("/items/{item_id}/vote/{token}", response_model=VoteResponse) +def submit_vote(item_id: UUID, token: str, vote_data: VoteRequest, db: Session = Depends(get_db)): + approval_token = db.query(ApprovalToken).filter(ApprovalToken.token == token).first() + if not approval_token: + raise HTTPException(status_code=404, detail="Invalid token") + + if approval_token.meal_plan_item_id != item_id: + raise HTTPException(status_code=400, detail="Token not valid for this meal") + + if approval_token.status != ApprovalTokenStatus.ACTIVE: + raise HTTPException(status_code=400, detail="Token has already been used or expired") + + if approval_token.expires_at < datetime.now(): + approval_token.status = ApprovalTokenStatus.EXPIRED + db.commit() + raise HTTPException(status_code=400, detail="Token has expired") + + existing_vote = db.query(MealPlanVote).filter( + MealPlanVote.meal_plan_item_id == item_id, + MealPlanVote.family_member_id == approval_token.family_member_id + ).first() + + if existing_vote: + raise HTTPException(status_code=400, detail="You have already voted on this meal") + + vote = MealPlanVote( + meal_plan_item_id=item_id, + family_member_id=approval_token.family_member_id, + vote=vote_data.vote + ) + db.add(vote) + + approval_token.status = ApprovalTokenStatus.USED + approval_token.used_at = datetime.now() + + item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first() + if not vote_data.vote and vote_data.denial_reason: + item.approval_status = MealPlanItemStatus.DENIED + item.denial_reason = vote_data.denial_reason + item.denial_details = vote_data.denial_details + + db.commit() + db.refresh(vote) + return vote + + +@router.get("/items/{item_id}", response_model=MealPlanItemResponse) +def get_meal_item(item_id: UUID, db: Session = Depends(get_db)): + item = db.query(MealPlanItem).options(joinedload(MealPlanItem.recipe)).filter( + MealPlanItem.id == item_id + ).first() + if not item: + raise HTTPException(status_code=404, detail="Meal plan item not found") + return item + + +@router.post("/items/{item_id}/swap") +def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get_db)): + item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="Meal plan item not found") + + new_recipe = db.query(Recipe).filter(Recipe.id == new_recipe_id).first() + if not new_recipe: + raise HTTPException(status_code=404, detail="New recipe not found") + + item.recipe_id = new_recipe_id + item.approval_status = MealPlanItemStatus.PENDING + item.denial_reason = None + item.denial_details = None + + db.commit() + return {"message": "Meal swapped", "item": item} \ No newline at end of file diff --git a/backend/app/api/pantry.py b/backend/app/api/pantry.py index 4ac1563..44c3e84 100644 --- a/backend/app/api/pantry.py +++ b/backend/app/api/pantry.py @@ -1,20 +1,81 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db +from app.models import HomePantry, Ingredient, FamilyProfile +from app.schemas import HomePantryResponse, HomePantryCreate +from uuid import UUID +from typing import List router = APIRouter() -@router.get("/") -def get_pantry(db: Session = Depends(get_db)): - return {"message": "Pantry endpoint - not yet implemented"} +@router.get("/", response_model=List[HomePantryResponse]) +def get_pantry_items(db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + items = db.query(HomePantry).filter( + HomePantry.family_profile_id == profile.id + ).all() + return items -@router.post("/") -def add_pantry_item(db: Session = Depends(get_db)): - return {"message": "Add pantry item - not yet implemented"} +@router.post("/", response_model=HomePantryResponse) +def add_pantry_item(item: HomePantryCreate, db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + existing = db.query(HomePantry).filter( + HomePantry.family_profile_id == profile.id, + HomePantry.ingredient_id == item.ingredient_id + ).first() + + if existing: + existing.quantity = item.quantity + existing.unit = item.unit + existing.expires_at = item.expires_at + db.commit() + db.refresh(existing) + return existing + + db_item = HomePantry( + family_profile_id=profile.id, + ingredient_id=item.ingredient_id, + quantity=item.quantity, + unit=item.unit, + expires_at=item.expires_at + ) + + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item @router.delete("/{item_id}") -def remove_pantry_item(item_id: str, db: Session = Depends(get_db)): - return {"message": f"Remove pantry item {item_id} - not yet implemented"} +def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)): + item = db.query(HomePantry).filter(HomePantry.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="Pantry item not found") + + db.delete(item) + db.commit() + return {"message": "Pantry item removed"} + + +@router.put("/{item_id}", response_model=HomePantryResponse) +def update_pantry_item(item_id: UUID, update: HomePantryCreate, db: Session = Depends(get_db)): + item = db.query(HomePantry).filter(HomePantry.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="Pantry item not found") + + item.ingredient_id = update.ingredient_id + item.quantity = update.quantity + item.unit = update.unit + item.expires_at = update.expires_at + + db.commit() + db.refresh(item) + return item \ No newline at end of file diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index ba31938..60fe248 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -1,15 +1,80 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db +from app.models import FamilyProfile, FamilyMember, DayOfWeek, MealType, MealPlanStatus, MealPlanItemStatus, ApprovalTokenStatus, FamilyMemberRole, DenialReason, NeverSuggestReason, ScrapeStatus, EmailStatus +from app.schemas import ( + FamilyProfileResponse, FamilyProfileUpdate, FamilyProfileCreate, + FamilyMemberResponse, FamilyMemberCreate +) +from uuid import UUID +from typing import List router = APIRouter() -@router.get("/") +@router.get("/", response_model=FamilyProfileResponse) def get_profile(db: Session = Depends(get_db)): - return {"message": "Profile endpoint - not yet implemented"} + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + return profile -@router.put("/") -def update_profile(db: Session = Depends(get_db)): - return {"message": "Update profile endpoint - not yet implemented"} +@router.put("/", response_model=FamilyProfileResponse) +def update_profile(update: FamilyProfileUpdate, db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(profile, field, value) + + db.commit() + db.refresh(profile) + return profile + + +@router.get("/members", response_model=List[FamilyMemberResponse]) +def get_members(db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + return profile.members + + +@router.post("/members", response_model=FamilyMemberResponse) +def add_member(member: FamilyMemberCreate, db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + existing = db.query(FamilyMember).filter( + FamilyMember.family_profile_id == profile.id, + FamilyMember.email == member.email + ).first() + if existing: + raise HTTPException(status_code=400, detail="Member with this email already exists") + + db_member = FamilyMember( + family_profile_id=profile.id, + name=member.name, + email=member.email, + role=FamilyMemberRole[member.role.value.upper()], + likes_mushrooms=member.likes_mushrooms + ) + db.add(db_member) + db.commit() + db.refresh(db_member) + return db_member + + +@router.delete("/members/{member_id}") +def delete_member(member_id: UUID, db: Session = Depends(get_db)): + member = db.query(FamilyMember).filter(FamilyMember.id == member_id).first() + if not member: + raise HTTPException(status_code=404, detail="Family member not found") + + db.delete(member) + db.commit() + return {"message": "Member deleted"} \ No newline at end of file diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py index f08b849..7baf557 100644 --- a/backend/app/api/recipes.py +++ b/backend/app/api/recipes.py @@ -1,20 +1,111 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload from app.database import get_db +from app.models import Recipe, FamilyProfile +from app.schemas import RecipeResponse, RecipeCreate, IngredientCreate, IngredientResponse +from uuid import UUID +from typing import List, Optional router = APIRouter() -@router.get("/") -def get_recipes(db: Session = Depends(get_db)): - return {"message": "Recipes endpoint - not yet implemented"} +@router.get("/", response_model=List[RecipeResponse]) +def get_recipes( + skip: int = 0, + limit: int = 50, + cuisine_tag: Optional[str] = None, + dietary_tag: Optional[str] = None, + protein_type: Optional[str] = None, + db: Session = Depends(get_db) +): + query = db.query(Recipe) + + if cuisine_tag: + query = query.filter(Recipe.cuisine_tags.contains([cuisine_tag])) + if dietary_tag: + query = query.filter(Recipe.dietary_tags.contains([dietary_tag])) + if protein_type: + query = query.filter(Recipe.protein_type == protein_type) + + recipes = query.offset(skip).limit(limit).all() + return recipes -@router.get("/{recipe_id}") -def get_recipe(recipe_id: str, db: Session = Depends(get_db)): - return {"message": f"Recipe {recipe_id} - not yet implemented"} +@router.get("/{recipe_id}", response_model=RecipeResponse) +def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)): + recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if not recipe: + raise HTTPException(status_code=404, detail="Recipe not found") + return recipe -@router.post("/") -def create_recipe(db: Session = Depends(get_db)): - return {"message": "Create recipe endpoint - not yet implemented"} +@router.post("/", response_model=RecipeResponse) +def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)): + profile = db.query(FamilyProfile).first() + + db_recipe = Recipe( + family_profile_id=profile.id if profile else None, + name=recipe.name, + description=recipe.description, + image_url=recipe.image_url, + image_source=recipe.image_source, + prep_time_minutes=recipe.prep_time_minutes, + cook_time_minutes=recipe.cook_time_minutes, + servings=recipe.servings, + servings_scaled=recipe.servings_scaled, + cuisine_tags=recipe.cuisine_tags, + dietary_tags=recipe.dietary_tags, + protein_type=recipe.protein_type, + spice_level=recipe.spice_level, + ingredients=[ing.model_dump() for ing in recipe.ingredients], + instructions=recipe.instructions, + source_url=recipe.source_url, + is_manually_added=recipe.is_manually_added + ) + + db.add(db_recipe) + db.commit() + db.refresh(db_recipe) + return db_recipe + + +@router.delete("/{recipe_id}") +def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)): + recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if not recipe: + raise HTTPException(status_code=404, detail="Recipe not found") + + db.delete(recipe) + db.commit() + return {"message": "Recipe deleted"} + + +@router.get("/ingredients/list", response_model=List[IngredientResponse]) +def list_ingredients(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + from app.models import Ingredient + ingredients = db.query(Ingredient).offset(skip).limit(limit).all() + return ingredients + + +@router.post("/ingredients", response_model=IngredientResponse) +def create_ingredient(ingredient: IngredientCreate, db: Session = Depends(get_db)): + from app.models import Ingredient + + existing = db.query(Ingredient).filter(Ingredient.name_lower == ingredient.name_lower).first() + if existing: + raise HTTPException(status_code=400, detail="Ingredient with this name already exists") + + db_ingredient = Ingredient( + name=ingredient.name, + name_lower=ingredient.name_lower, + plural_name=ingredient.plural_name, + aisle=ingredient.aisle, + typical_price=ingredient.typical_price, + unit=ingredient.unit, + season_months=ingredient.season_months + ) + + db.add(db_ingredient) + db.commit() + db.refresh(db_ingredient) + return db_ingredient \ No newline at end of file diff --git a/backend/app/api/shopping_list.py b/backend/app/api/shopping_list.py index aa97e93..c965c25 100644 --- a/backend/app/api/shopping_list.py +++ b/backend/app/api/shopping_list.py @@ -1,15 +1,175 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from sqlalchemy import func from app.database import get_db +from app.models import ( + MealPlan, MealPlanItem, Recipe, HomePantry, + FamilyProfile, Ingredient, GroceryItem +) +from app.schemas import ShoppingListResponse, ShoppingListItem +from datetime import date +from typing import List +from collections import defaultdict router = APIRouter() -@router.get("/") +@router.get("/", response_model=ShoppingListResponse) def get_shopping_list(db: Session = Depends(get_db)): - return {"message": "Shopping list endpoint - not yet implemented"} + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + current_plan = db.query(MealPlan).filter( + MealPlan.family_profile_id == profile.id, + MealPlan.status.in_(['approved', 'locked']) + ).order_by(MealPlan.week_start_date.desc()).first() + + if not current_plan: + current_plan = db.query(MealPlan).filter( + MealPlan.family_profile_id == profile.id + ).order_by(MealPlan.week_start_date.desc()).first() + + if not current_plan: + return ShoppingListResponse( + week_start_date=date.today(), + items=[], + total_estimated_cost=0.0, + sale_items_count=0, + by_aisle={} + ) + + pantry_items = { + p.ingredient_id: p for p in db.query(HomePantry).filter( + HomePantry.family_profile_id == profile.id + ).all() + } + + ingredient_map = {} + all_ingredient_ids = set() + for item in current_plan.items: + recipe = db.query(Recipe).filter(Recipe.id == item.recipe_id).first() + if recipe and recipe.ingredients: + for ing in recipe.ingredients: + if ing.get('ingredient_id'): + all_ingredient_ids.add(ing['ingredient_id']) + + if all_ingredient_ids: + ingredients = db.query(Ingredient).filter( + Ingredient.id.in_ all_ingredient_ids + ).all() + ingredient_map = {i.id: i for i in ingredients} + + grocery_items = {} + if all_ingredient_ids: + groceries = db.query(GroceryItem).filter( + GroceryItem.ingredient_id.in_(all_ingredient_ids), + GroceryItem.is_on_sale == True + ).all() + for g in groceries: + grocery_items[g.ingredient_id] = g + + aggregated = defaultdict(lambda: {"quantity": 0.0, "unit": None, "name": ""}) + + for plan_item in current_plan.items: + recipe = db.query(Recipe).filter(Recipe.id == plan_item.recipe_id).first() + if recipe and recipe.ingredients: + for ing in recipe.ingredients: + name = ing.get('name', 'Unknown') + quantity = ing.get('quantity', 1.0) or 1.0 + unit = ing.get('unit') + ing_id = ing.get('ingredient_id') + + key = name.lower() + aggregated[key]["quantity"] += quantity + aggregated[key]["unit"] = unit + aggregated[key]["name"] = name + aggregated[key]["ingredient_id"] = ing_id + + shopping_items = [] + total_cost = 0.0 + sale_count = 0 + + for name_key, data in aggregated.items(): + ingredient_id = data.get("ingredient_id") + in_pantry = ingredient_id and ingredient_id in pantry_items + + ing_obj = ingredient_map.get(ingredient_id) if ingredient_id else None + price = ing_obj.typical_price if ing_obj else None + + sale_price = None + is_on_sale = False + if ingredient_id and ingredient_id in grocery_items: + g = grocery_items[ingredient_id] + is_on_sale = True + sale_price = float(g.current_price) if g.current_price else None + price = sale_price + sale_count += 1 + + if price: + total_cost += price * data["quantity"] + + shopping_items.append(ShoppingListItem( + ingredient_id=ingredient_id, + name=data["name"], + quantity=data["quantity"], + unit=data["unit"], + aisle=ing_obj.aisle if ing_obj else None, + estimated_price=price, + is_on_sale=is_on_sale, + sale_price=sale_price, + in_season=grocery_items.get(ingredient_id).in_season if ingredient_id and ingredient_id in grocery_items else False, + in_pantry=in_pantry + )) + + by_aisle = defaultdict(list) + for item in shopping_items: + aisle = item.aisle or "Other" + by_aisle[aisle].append(item) + + return ShoppingListResponse( + week_start_date=current_plan.week_start_date, + items=shopping_items, + total_estimated_cost=round(total_cost, 2), + sale_items_count=sale_count, + by_aisle=dict(by_aisle) + ) @router.get("/print") -def get_printable_shopping_list(db: Session = Depends(get_db)): - return {"message": "Printable shopping list - not yet implemented"} +def print_shopping_list(db: Session = Depends(get_db)): + shopping_list = get_shopping_list.__wrapped__(None, db) + + html = f""" + + + + Shopping List - Week of {shopping_list.week_start_date} + + + +

Shopping List - Week of {shopping_list.week_start_date}

+
Estimated Total: ${shopping_list.total_estimated_cost:.2f}
+
Sale Items: {shopping_list.sale_items_count}
+ """ + + for aisle, items in shopping_list.by_aisle.items(): + html += f'

{aisle}

' + for item in items: + sale_class = 'sale' if item.is_on_sale else '' + price = f'${item.sale_price:.2f}' if item.sale_price else (f'${item.estimated_price:.2f}' if item.estimated_price else '') + html += f'
{item.name}{item.quantity} {item.unit or ""} {price}
' + html += '
' + + html += '' + + return {"html": html} \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..c8b3497 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,294 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Any +from uuid import UUID +from datetime import date, datetime +from enum import Enum + + +class FamilyMemberRole(str, Enum): + adult = "adult" + child = "child" + + +class MealType(str, Enum): + breakfast = "breakfast" + lunch = "lunch" + dinner = "dinner" + + +class MealPlanStatus(str, Enum): + draft = "draft" + pending_approval = "pending_approval" + approved = "approved" + locked = "locked" + + +class MealPlanItemStatus(str, Enum): + pending = "pending" + approved = "approved" + denied = "denied" + swapped = "swapped" + + +class DenialReason(str, Enum): + too_expensive = "too_expensive" + boring = "boring" + disliked_ingredient = "disliked_ingredient" + cultural = "cultural" + other = "other" + + +class NeverSuggestReason(str, Enum): + allergy = "allergy" + dislike = "dislike" + tried_too_much = "tried_too_much" + other = "other" + + +class IngredientBase(BaseModel): + name: str + name_lower: str + plural_name: Optional[str] = None + aisle: Optional[str] = None + typical_price: Optional[float] = None + unit: Optional[str] = None + season_months: Optional[List[int]] = None + + +class IngredientResponse(IngredientBase): + id: UUID + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class IngredientCreate(IngredientBase): + pass + + +class FamilyMemberBase(BaseModel): + name: str + email: Optional[str] = None + role: FamilyMemberRole + likes_mushrooms: bool = False + + +class FamilyMemberResponse(FamilyMemberBase): + id: UUID + family_profile_id: UUID + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class FamilyMemberCreate(FamilyMemberBase): + pass + + +class FamilyProfileBase(BaseModel): + name: str + household_size: int + adult_count: int + child_count: int + dietary_notes: Optional[str] = None + budget_per_meal: float = 50.00 + + +class FamilyProfileResponse(FamilyProfileBase): + id: UUID + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + members: List[FamilyMemberResponse] = [] + + class Config: + from_attributes = True + + +class FamilyProfileCreate(FamilyProfileBase): + pass + + +class FamilyProfileUpdate(BaseModel): + name: Optional[str] = None + household_size: Optional[int] = None + adult_count: Optional[int] = None + child_count: Optional[int] = None + dietary_notes: Optional[str] = None + budget_per_meal: Optional[float] = None + + +class RecipeIngredient(BaseModel): + ingredient_id: Optional[UUID] = None + name: str + quantity: Optional[float] = None + unit: Optional[str] = None + is_optional: bool = False + + +class RecipeBase(BaseModel): + name: str + description: Optional[str] = None + image_url: Optional[str] = None + image_source: Optional[str] = None + prep_time_minutes: Optional[int] = None + cook_time_minutes: Optional[int] = None + servings: int + servings_scaled: Optional[int] = None + cuisine_tags: Optional[List[str]] = [] + dietary_tags: Optional[List[str]] = [] + protein_type: Optional[str] = None + spice_level: Optional[int] = None + ingredients: List[RecipeIngredient] = [] + instructions: List[str] = [] + source_url: Optional[str] = None + is_manually_added: bool = False + + +class RecipeResponse(RecipeBase): + id: UUID + family_profile_id: Optional[UUID] = None + scraped_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + total_time_minutes: Optional[int] = None + + class Config: + from_attributes = True + + +class RecipeCreate(RecipeBase): + pass + + +class MealPlanItemBase(BaseModel): + recipe_id: UUID + day_of_week: int = Field(..., ge=1, le=7) + meal_type: MealType + estimated_cost: Optional[float] = None + + +class MealPlanItemResponse(MealPlanItemBase): + id: UUID + meal_plan_id: UUID + approval_status: MealPlanItemStatus = MealPlanItemStatus.pending + denial_reason: Optional[DenialReason] = None + denial_details: Optional[str] = None + used_pantry_items: Optional[List[UUID]] = [] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + recipe: Optional[RecipeResponse] = None + + class Config: + from_attributes = True + + +class MealPlanItemCreate(MealPlanItemBase): + pass + + +class MealPlanBase(BaseModel): + week_start_date: date + status: MealPlanStatus = MealPlanStatus.draft + approval_deadline: Optional[datetime] = None + notes: Optional[str] = None + + +class MealPlanResponse(MealPlanBase): + id: UUID + family_profile_id: UUID + total_estimated_cost: Optional[float] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + items: List[MealPlanItemResponse] = [] + + class Config: + from_attributes = True + + +class MealPlanCreate(MealPlanBase): + items: List[MealPlanItemCreate] = [] + + +class VoteRequest(BaseModel): + vote: bool + denial_reason: Optional[DenialReason] = None + denial_details: Optional[str] = None + + +class VoteResponse(BaseModel): + id: UUID + meal_plan_item_id: UUID + family_member_id: UUID + vote: bool + voted_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class HomePantryBase(BaseModel): + ingredient_id: UUID + quantity: Optional[float] = None + unit: Optional[str] = None + expires_at: Optional[date] = None + + +class HomePantryResponse(HomePantryBase): + id: UUID + family_profile_id: UUID + added_at: Optional[datetime] = None + created_at: Optional[datetime] = None + ingredient: Optional[IngredientResponse] = None + + class Config: + from_attributes = True + + +class HomePantryCreate(HomePantryBase): + pass + + +class FeedbackBase(BaseModel): + rating: Optional[int] = Field(None, ge=1, le=5) + never_suggest: bool = False + denial_reason: Optional[DenialReason] = None + feedback_text: Optional[str] = None + + +class FeedbackResponse(FeedbackBase): + id: UUID + family_profile_id: UUID + family_member_id: Optional[UUID] = None + meal_plan_item_id: UUID + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class FeedbackCreate(FeedbackBase): + meal_plan_item_id: UUID + + +class ShoppingListItem(BaseModel): + ingredient_id: Optional[UUID] = None + name: str + quantity: Optional[float] = None + unit: Optional[str] = None + aisle: Optional[str] = None + estimated_price: Optional[float] = None + is_on_sale: bool = False + sale_price: Optional[float] = None + in_season: bool = False + in_pantry: bool = False + + +class ShoppingListResponse(BaseModel): + week_start_date: date + items: List[ShoppingListItem] + total_estimated_cost: float + sale_items_count: int + by_aisle: dict[str, List[ShoppingListItem]] \ No newline at end of file diff --git a/docs/ORIENTATION.md b/docs/ORIENTATION.md index 2a98dec..728a2f9 100644 --- a/docs/ORIENTATION.md +++ b/docs/ORIENTATION.md @@ -18,7 +18,7 @@ The family has been using meal kit services (Blue Apron → EveryPlate → Hungr ### Current Status -**Phase**: Post-adversarial-review fixes applied. Ready for verification. +**Phase**: Phase 2 (Database & Models) IN PROGRESS. Migrations created, schemas implemented, API endpoints implemented. **Adversarial Review Completed**: 2026-05-04 - All consensus blockers (§1.1 - §1.8) addressed @@ -33,6 +33,17 @@ The family has been using meal kit services (Blue Apron → EveryPlate → Hungr - Docker: Hardened (no direct port exposure to backend/frontend) - Alembic: Configured with migration policy +**Phase 2 Progress** (2026-05-04): +- [x] Initial Alembic migration (0001_initial_migration.py) +- [x] Seed data migration (0002_seed_data.py) +- [x] Pydantic schemas for all models +- [x] /api/profile endpoints (CRUD, family members) +- [x] /api/recipes endpoints (CRUD, ingredients) +- [x] /api/meals endpoints (meal plans, voting, approval tokens) +- [x] /api/pantry endpoints (CRUD) +- [x] /api/shopping-list endpoints (aggregation, print) +- [x] /api/admin endpoints (scrape trigger, logs, stats) + --- ## Architecture Summary