Public Access
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:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user