From ed3255adb6b1bbdb85cf15a5c92cc0530bd3cf37 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 06:29:33 -0700 Subject: [PATCH] feat: migration 0007 - seed canonical ingredients with aliases (recipes follow in P4-14) --- backend/alembic/versions/0007_seed_recipes.py | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 backend/alembic/versions/0007_seed_recipes.py diff --git a/backend/alembic/versions/0007_seed_recipes.py b/backend/alembic/versions/0007_seed_recipes.py new file mode 100644 index 0000000..622c4b4 --- /dev/null +++ b/backend/alembic/versions/0007_seed_recipes.py @@ -0,0 +1,140 @@ +"""seed canonical ingredients (and 30 starter recipes in P4-14) + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-05-05 +""" +from decimal import Decimal + +from alembic import op +import sqlalchemy as sa + + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +# Canonical ingredients used by the 30 starter recipes (P4-14 references them +# by name_lower so this list stays the source of truth for both seed steps). +INGREDIENTS = [ + # (name, aliases, aisle, unit, typical_price) + ("Chicken Thighs, Boneless Skinless", ["chicken thigh", "BSL chicken thighs"], "meat_seafood", "lb", Decimal("4.99")), + ("Chicken Breast, Boneless Skinless", ["chicken breast", "BSL chicken breast"], "meat_seafood", "lb", Decimal("5.99")), + ("Ground Beef, 85/15", ["ground beef", "hamburger"], "meat_seafood", "lb", Decimal("6.99")), + ("Ground Turkey", ["turkey mince"], "meat_seafood", "lb", Decimal("5.49")), + ("Pork Chops, Bone-In", ["pork chop"], "meat_seafood", "lb", Decimal("4.49")), + ("Salmon Fillet", ["salmon"], "meat_seafood", "lb", Decimal("12.99")), + ("Shrimp, Peeled", ["shrimp"], "meat_seafood", "lb", Decimal("9.99")), + ("Eggs, Large", ["egg"], "dairy", "ea", Decimal("0.40")), + ("Black Beans, Canned", ["black bean"], "pantry", "can", Decimal("1.29")), + ("Chickpeas, Canned", ["garbanzo", "chickpea"], "pantry", "can", Decimal("1.49")), + + ("Yellow Onion", ["onion"], "produce", "ea", Decimal("0.99")), + ("Garlic", ["garlic clove"], "produce", "clove", Decimal("0.10")), + ("Carrot", ["carrots"], "produce", "ea", Decimal("0.50")), + ("Celery", [], "produce", "stalk", Decimal("0.30")), + ("Bell Pepper, Red", ["red pepper"], "produce", "ea", Decimal("1.49")), + ("Bell Pepper, Green", ["green pepper"], "produce", "ea", Decimal("0.99")), + ("Tomato, Roma", ["tomato"], "produce", "ea", Decimal("0.79")), + ("Lemon", [], "produce", "ea", Decimal("0.79")), + ("Lime", [], "produce", "ea", Decimal("0.50")), + ("Cilantro", ["coriander leaf"], "produce", "bunch", Decimal("0.99")), + ("Parsley, Italian", ["parsley"], "produce", "bunch", Decimal("0.99")), + ("Spinach, Fresh", ["spinach"], "produce", "oz", Decimal("0.40")), + ("Broccoli", [], "produce", "lb", Decimal("2.49")), + ("Zucchini", [], "produce", "ea", Decimal("0.99")), + ("Sweet Potato", [], "produce", "ea", Decimal("1.29")), + + ("Olive Oil", ["EVOO", "extra virgin olive oil"], "pantry", "tbsp", Decimal("0.20")), + ("Soy Sauce", [], "pantry", "tbsp", Decimal("0.15")), + ("Rice, Long-Grain White", ["white rice", "rice"], "pantry", "cup", Decimal("0.40")), + ("Pasta, Penne", ["penne"], "pantry", "lb", Decimal("1.29")), + ("Pasta, Spaghetti", ["spaghetti"], "pantry", "lb", Decimal("1.29")), + ("Tortilla, Flour", ["flour tortilla"], "pantry", "ea", Decimal("0.30")), + ("Tortilla, Corn", ["corn tortilla"], "pantry", "ea", Decimal("0.20")), + ("Diced Tomatoes, Canned", ["canned tomato"], "pantry", "can", Decimal("1.49")), + ("Chicken Broth", ["chicken stock"], "pantry", "cup", Decimal("0.30")), + ("Coconut Milk, Canned", [], "pantry", "can", Decimal("2.49")), + ("Salt, Kosher", ["kosher salt"], "pantry", "tsp", Decimal("0.01")), + ("Black Pepper", ["pepper"], "pantry", "tsp", Decimal("0.02")), + ("Cumin, Ground", ["cumin"], "pantry", "tsp", Decimal("0.10")), + ("Paprika, Smoked", ["smoked paprika"], "pantry", "tsp", Decimal("0.10")), + ("Italian Seasoning", [], "pantry", "tsp", Decimal("0.10")), + ("Curry Powder", [], "pantry", "tsp", Decimal("0.10")), + ("Ginger, Fresh", ["ginger root"], "produce", "tbsp", Decimal("0.30")), + + ("Cheddar Cheese, Sharp", ["cheddar"], "dairy", "oz", Decimal("0.40")), + ("Mozzarella, Shredded", ["mozzarella"], "dairy", "oz", Decimal("0.45")), + ("Parmesan, Grated", ["parm"], "dairy", "tbsp", Decimal("0.20")), + ("Sour Cream", [], "dairy", "tbsp", Decimal("0.10")), + ("Greek Yogurt, Plain", ["yogurt"], "dairy", "cup", Decimal("1.50")), + ("Butter, Unsalted", ["butter"], "dairy", "tbsp", Decimal("0.20")), + ("Milk, Whole", ["milk"], "dairy", "cup", Decimal("0.30")), + + ("Avocado", [], "produce", "ea", Decimal("1.49")), + ("Salsa, Jarred", ["salsa"], "pantry", "tbsp", Decimal("0.15")), +] + + +def _upsert_ingredients() -> None: + bind = op.get_bind() + for name, aliases, aisle, unit, typical_price in INGREDIENTS: + bind.execute( + sa.text( + """ + INSERT INTO ingredient (id, name, name_lower, aliases, aisle, unit, typical_price) + VALUES ( + gen_random_uuid(), :name, :name_lower, :aliases, :aisle, :unit, :typical_price + ) + ON CONFLICT (name_lower) DO UPDATE SET + aliases = EXCLUDED.aliases, + aisle = COALESCE(EXCLUDED.aisle, ingredient.aisle), + unit = COALESCE(EXCLUDED.unit, ingredient.unit), + typical_price = COALESCE(EXCLUDED.typical_price, ingredient.typical_price) + """ + ), + { + "name": name, + "name_lower": name.lower(), + "aliases": aliases, + "aisle": aisle, + "unit": unit, + "typical_price": typical_price, + }, + ) + + +def _insert_recipes() -> None: + """Populated in P4-14.""" + pass + + +def _delete_recipes() -> None: + """Populated in P4-14.""" + pass + + +def _delete_ingredients() -> None: + """Best-effort: only remove rows whose name_lower matches one of our seeds. + Pre-existing rows from migration 0002 may share names; their aliases/aisle + will revert to whatever was in 0002 (NULL aliases pre-0006). For a clean + rollback in this scenario, run alembic downgrade past 0002 as well. + """ + bind = op.get_bind() + name_lowers = [name.lower() for name, *_ in INGREDIENTS] + bind.execute( + sa.text("DELETE FROM ingredient WHERE name_lower = ANY(:nl)"), + {"nl": name_lowers}, + ) + + +def upgrade() -> None: + _upsert_ingredients() + _insert_recipes() + + +def downgrade() -> None: + _delete_recipes() + _delete_ingredients()