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
This commit is contained in:
2026-05-04 20:21:20 -07:00
parent e8706d31b2
commit c735d21661
10 changed files with 1452 additions and 49 deletions
@@ -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
+114
View File
@@ -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
+153 -7
View File
@@ -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
}
+182 -10
View File
@@ -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}
+70 -9
View File
@@ -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
+71 -6
View File
@@ -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"}
+102 -11
View File
@@ -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
+165 -5
View File
@@ -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"""
<!DOCTYPE html>
<html>
<head>
<title>Shopping List - Week of {shopping_list.week_start_date}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
.aisle {{ margin: 20px 0; }}
.aisle h2 {{ background: #f5f5f5; padding: 10px; margin: 0; }}
.item {{ padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }}
.item .name {{ flex: 1; }}
.sale {{ color: red; font-weight: bold; }}
.total {{ margin-top: 30px; font-size: 1.2em; font-weight: bold; }}
</style>
</head>
<body>
<h1>Shopping List - Week of {shopping_list.week_start_date}</h1>
<div class="total">Estimated Total: ${shopping_list.total_estimated_cost:.2f}</div>
<div class="total">Sale Items: {shopping_list.sale_items_count}</div>
"""
for aisle, items in shopping_list.by_aisle.items():
html += f'<div class="aisle"><h2>{aisle}</h2>'
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'<div class="item {sale_class}"><span class="name">{item.name}</span><span>{item.quantity} {item.unit or ""} {price}</span></div>'
html += '</div>'
html += '</body></html>'
return {"html": html}
+294
View File
@@ -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]]
+12 -1
View File
@@ -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