""" Migration: Recipe, Ingredient & Food Flag System Creates all tables for the recipe system and pre-seeds reference data. """ import asyncio from sqlalchemy import text from database import engine async def migrate(): # ══ CRITICAL: Add columns to existing tables FIRST (own transaction) ══ # These MUST succeed even if new table creation fails, otherwise # the KitchenSettings SQLAlchemy model breaks ALL existing endpoints. try: async with engine.begin() as conn: await conn.execute(text( "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS api_key VARCHAR(100)" )) await conn.execute(text( "ALTER TABLE kitchen_settings ADD COLUMN IF NOT EXISTS api_key_enabled BOOLEAN DEFAULT false" )) await conn.execute(text( "ALTER TABLE line_items ADD COLUMN IF NOT EXISTS ingredient_id INTEGER REFERENCES ingredients(id) ON DELETE SET NULL" )) await conn.execute(text( "CREATE INDEX IF NOT EXISTS idx_line_items_ingredient ON line_items(ingredient_id)" )) print("+ Added columns to existing tables (api_key, api_key_enabled, ingredient_id)") except Exception as e: print(f"! CRITICAL: Failed to add columns to existing tables: {e}") # ── pg_trgm extension (separate transaction — may need superuser) ── try: async with engine.begin() as conn: await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) print("+ Enabled pg_trgm extension") except Exception as e: print(f"- pg_trgm extension not available ({e}), fuzzy search will be limited") # ══ Add missing UNIQUE constraints (tables may have been created by # SQLAlchemy create_all WITHOUT these constraints) ══ try: async with engine.begin() as conn: for idx_name, idx_def in [ ("uix_ingredient_categories_kn", "ingredient_categories(kitchen_id, name)"), ("uix_ingredients_kn", "ingredients(kitchen_id, name)"), ("uix_ingredient_sources_kisc", "ingredient_sources(kitchen_id, ingredient_id, supplier_id, product_code)"), ("uix_ingredient_flags_if", "ingredient_flags(ingredient_id, food_flag_id)"), ("uix_food_flag_categories_kn", "food_flag_categories(kitchen_id, name)"), ("uix_food_flags_kn", "food_flags(kitchen_id, name)"), ("uix_line_item_flags_lf", "line_item_flags(line_item_id, food_flag_id)"), ("uix_menu_sections_kn", "menu_sections(kitchen_id, name)"), ("uix_recipes_kn", "recipes(kitchen_id, name)"), ("uix_recipe_flags_rf", "recipe_flags(recipe_id, food_flag_id)"), ("uix_recipe_cost_snapshots_rd", "recipe_cost_snapshots(recipe_id, snapshot_date)"), ]: await conn.execute(text( f"CREATE UNIQUE INDEX IF NOT EXISTS {idx_name} ON {idx_def}" )) print("+ Ensured all unique constraints/indexes exist") except Exception as e: print(f"! Warning adding unique indexes: {e}") async with engine.begin() as conn: # ── ingredient_categories ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS ingredient_categories ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(100) NOT NULL, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, name) ) """)) print("+ Created ingredient_categories table") except Exception as e: if "already exists" in str(e).lower(): print("- ingredient_categories table already exists, skipping") else: raise # ── ingredients ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS ingredients ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(255) NOT NULL, category_id INTEGER REFERENCES ingredient_categories(id) ON DELETE SET NULL, standard_unit VARCHAR(20) NOT NULL, yield_percent NUMERIC(5,2) DEFAULT 100.00, manual_price NUMERIC(12,6), notes TEXT, is_archived BOOLEAN DEFAULT false, created_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, name) ) """)) print("+ Created ingredients table") except Exception as e: if "already exists" in str(e).lower(): print("- ingredients table already exists, skipping") else: raise # ── ingredient_sources ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS ingredient_sources ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, supplier_id INTEGER NOT NULL REFERENCES suppliers(id), product_code VARCHAR(100), description_pattern VARCHAR(255), pack_quantity INTEGER, unit_size NUMERIC(10,3), unit_size_type VARCHAR(10), latest_unit_price NUMERIC(10,2), latest_invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL, latest_invoice_date DATE, price_per_std_unit NUMERIC(12,6), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, ingredient_id, supplier_id, product_code) ) """)) print("+ Created ingredient_sources table") except Exception as e: if "already exists" in str(e).lower(): print("- ingredient_sources table already exists, skipping") else: raise # Partial unique index for no-SKU items (product_code IS NULL) try: await conn.execute(text(""" CREATE UNIQUE INDEX IF NOT EXISTS uix_ingredient_source_desc ON ingredient_sources(kitchen_id, ingredient_id, supplier_id, description_pattern) WHERE product_code IS NULL """)) print("+ Created partial unique index uix_ingredient_source_desc") except Exception as e: if "already exists" in str(e).lower(): print("- Index uix_ingredient_source_desc already exists, skipping") else: raise # ── food_flag_categories ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS food_flag_categories ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(100) NOT NULL, propagation_type VARCHAR(20) NOT NULL, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, name) ) """)) print("+ Created food_flag_categories table") except Exception as e: if "already exists" in str(e).lower(): print("- food_flag_categories table already exists, skipping") else: raise # ── food_flags ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS food_flags ( id SERIAL PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES food_flag_categories(id) ON DELETE CASCADE, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(100) NOT NULL, code VARCHAR(10), icon VARCHAR(10), sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, name) ) """)) print("+ Created food_flags table") except Exception as e: if "already exists" in str(e).lower(): print("- food_flags table already exists, skipping") else: raise # ── ingredient_flags (canonical flag source) ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS ingredient_flags ( id SERIAL PRIMARY KEY, ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE, food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE, flagged_by INTEGER REFERENCES users(id), source VARCHAR(20) DEFAULT 'manual', created_at TIMESTAMP DEFAULT NOW(), UNIQUE(ingredient_id, food_flag_id) ) """)) print("+ Created ingredient_flags table") except Exception as e: if "already exists" in str(e).lower(): print("- ingredient_flags table already exists, skipping") else: raise # ── line_item_flags (data entry mechanism) ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS line_item_flags ( id SERIAL PRIMARY KEY, line_item_id INTEGER NOT NULL REFERENCES line_items(id) ON DELETE CASCADE, food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE, flagged_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW(), UNIQUE(line_item_id, food_flag_id) ) """)) print("+ Created line_item_flags table") except Exception as e: if "already exists" in str(e).lower(): print("- line_item_flags table already exists, skipping") else: raise # ── menu_sections ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS menu_sections ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(100) NOT NULL, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, name) ) """)) print("+ Created menu_sections table") except Exception as e: if "already exists" in str(e).lower(): print("- menu_sections table already exists, skipping") else: raise # ── recipes ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipes ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(255) NOT NULL, recipe_type VARCHAR(20) NOT NULL, menu_section_id INTEGER REFERENCES menu_sections(id) ON DELETE SET NULL, description TEXT, batch_portions INTEGER NOT NULL DEFAULT 1, prep_time_minutes INTEGER, cook_time_minutes INTEGER, notes TEXT, is_archived BOOLEAN DEFAULT false, kds_menu_item_name VARCHAR(255), created_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(kitchen_id, name) ) """)) print("+ Created recipes table") except Exception as e: if "already exists" in str(e).lower(): print("- recipes table already exists, skipping") else: raise # ── recipe_ingredients ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_ingredients ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE RESTRICT, quantity NUMERIC(10,3) NOT NULL, notes TEXT, sort_order INTEGER DEFAULT 0 ) """)) print("+ Created recipe_ingredients table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_ingredients table already exists, skipping") else: raise # ── recipe_sub_recipes ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_sub_recipes ( id SERIAL PRIMARY KEY, parent_recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, child_recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE RESTRICT, portions_needed NUMERIC(10,3) NOT NULL, notes TEXT, sort_order INTEGER DEFAULT 0, CHECK(parent_recipe_id != child_recipe_id) ) """)) print("+ Created recipe_sub_recipes table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_sub_recipes table already exists, skipping") else: raise # ── recipe_steps ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_steps ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, step_number INTEGER NOT NULL, instruction TEXT NOT NULL, image_path VARCHAR(500), duration_minutes INTEGER, notes TEXT ) """)) print("+ Created recipe_steps table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_steps table already exists, skipping") else: raise # ── recipe_images ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_images ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, image_path VARCHAR(500) NOT NULL, caption TEXT, image_type VARCHAR(20) DEFAULT 'general', sort_order INTEGER DEFAULT 0, uploaded_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() ) """)) print("+ Created recipe_images table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_images table already exists, skipping") else: raise # ── recipe_flags (manual additions + override state) ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_flags ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE, source_type VARCHAR(20) NOT NULL, is_active BOOLEAN DEFAULT true, excludable_on_request BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(recipe_id, food_flag_id) ) """)) print("+ Created recipe_flags table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_flags table already exists, skipping") else: raise # ── recipe_flag_overrides (audit log) ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_flag_overrides ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, food_flag_id INTEGER NOT NULL REFERENCES food_flags(id) ON DELETE CASCADE, action VARCHAR(20) NOT NULL, note TEXT NOT NULL, user_id INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() ) """)) print("+ Created recipe_flag_overrides table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_flag_overrides table already exists, skipping") else: raise # ── recipe_change_log ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_change_log ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, change_summary TEXT NOT NULL, user_id INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() ) """)) print("+ Created recipe_change_log table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_change_log table already exists, skipping") else: raise # ── recipe_cost_snapshots ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS recipe_cost_snapshots ( id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, cost_per_portion NUMERIC(12,6) NOT NULL, total_cost NUMERIC(12,6) NOT NULL, snapshot_date DATE NOT NULL, trigger_source VARCHAR(100), created_at TIMESTAMP DEFAULT NOW(), UNIQUE(recipe_id, snapshot_date) ) """)) print("+ Created recipe_cost_snapshots table") except Exception as e: if "already exists" in str(e).lower(): print("- recipe_cost_snapshots table already exists, skipping") else: raise # ── event_orders ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS event_orders ( id SERIAL PRIMARY KEY, kitchen_id INTEGER NOT NULL REFERENCES kitchens(id), name VARCHAR(255) NOT NULL, event_date DATE, notes TEXT, status VARCHAR(20) DEFAULT 'DRAFT', created_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ) """)) print("+ Created event_orders table") except Exception as e: if "already exists" in str(e).lower(): print("- event_orders table already exists, skipping") else: raise # ── event_order_items ── try: await conn.execute(text(""" CREATE TABLE IF NOT EXISTS event_order_items ( id SERIAL PRIMARY KEY, event_order_id INTEGER NOT NULL REFERENCES event_orders(id) ON DELETE CASCADE, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE RESTRICT, quantity INTEGER NOT NULL, notes TEXT, sort_order INTEGER DEFAULT 0 ) """)) print("+ Created event_order_items table") except Exception as e: if "already exists" in str(e).lower(): print("- event_order_items table already exists, skipping") else: raise # (ALTER TABLE for existing tables already ran in separate transaction above) # ── Indexes ── for idx_name, idx_def in [ ("idx_ingredients_kitchen", "ingredients(kitchen_id)"), ("idx_ingredients_category", "ingredients(category_id)"), ("idx_ingredient_sources_ingredient", "ingredient_sources(ingredient_id)"), ("idx_ingredient_sources_supplier", "ingredient_sources(supplier_id)"), ("idx_ingredient_flags_ingredient", "ingredient_flags(ingredient_id)"), ("idx_ingredient_flags_flag", "ingredient_flags(food_flag_id)"), ("idx_food_flags_category", "food_flags(category_id)"), ("idx_line_item_flags_line_item", "line_item_flags(line_item_id)"), ("idx_recipes_kitchen", "recipes(kitchen_id)"), ("idx_recipes_type", "recipes(kitchen_id, recipe_type)"), ("idx_recipes_section", "recipes(menu_section_id)"), ("idx_recipe_ingredients_recipe", "recipe_ingredients(recipe_id)"), ("idx_recipe_sub_recipes_parent", "recipe_sub_recipes(parent_recipe_id)"), ("idx_recipe_sub_recipes_child", "recipe_sub_recipes(child_recipe_id)"), ("idx_recipe_steps_recipe", "recipe_steps(recipe_id)"), ("idx_recipe_images_recipe", "recipe_images(recipe_id)"), ("idx_recipe_flags_recipe", "recipe_flags(recipe_id)"), ("idx_recipe_cost_snapshots_recipe", "recipe_cost_snapshots(recipe_id)"), ("idx_event_orders_kitchen", "event_orders(kitchen_id)"), ("idx_event_order_items_order", "event_order_items(event_order_id)"), ]: try: await conn.execute(text( f"CREATE INDEX IF NOT EXISTS {idx_name} ON {idx_def}" )) except Exception: pass # Skip silently — indexes are non-critical if they exist print("+ Created indexes") # ══ Seeding in separate transaction (so failures don't rollback table creation) ══ async with engine.begin() as conn: # ── Pre-seed ingredient categories for all existing kitchens ── try: result = await conn.execute(text("SELECT id FROM kitchens")) kitchen_ids = [row[0] for row in result.fetchall()] categories = [ "Dairy", "Meat", "Seafood", "Produce", "Dry Goods", "Oils & Fats", "Herbs & Spices", "Bakery", "Beverages", "Condiments", "Other" ] for kid in kitchen_ids: for i, cat in enumerate(categories): await conn.execute(text(""" INSERT INTO ingredient_categories (kitchen_id, name, sort_order, created_at) VALUES (:kid, :name, :sort, NOW()) ON CONFLICT (kitchen_id, name) DO NOTHING """), {"kid": kid, "name": cat, "sort": i}) print(f"+ Pre-seeded ingredient categories for {len(kitchen_ids)} kitchen(s)") except Exception as e: print(f"! Warning seeding ingredient categories: {e}") # ── Pre-seed food flag categories and flags ── try: allergy_flags = [ ("Celery", "Ce", None), ("Gluten", "Gl", None), ("Crustaceans", "Cr", None), ("Eggs", "Eg", None), ("Fish", "Fi", None), ("Lupin", "Lu", None), ("Milk", "Mi", None), ("Molluscs", "Mo", None), ("Mustard", "Mu", None), ("Tree Nuts", "TN", None), ("Peanuts", "Pn", None), ("Sesame", "Se", None), ("Soya", "So", None), ("Sulphites", "Su", None), ] dietary_flags = [ ("Vegetarian", "V", None), ("Vegan", "Ve", None), ("Pescatarian", "Pe", None), ("Gluten-Free", "GF", None), ] for kid in kitchen_ids: # Create Allergy category (only seed flags if category is new) result = await conn.execute(text(""" INSERT INTO food_flag_categories (kitchen_id, name, propagation_type, sort_order, created_at) VALUES (:kid, 'Allergy', 'contains', 0, NOW()) ON CONFLICT (kitchen_id, name) DO NOTHING RETURNING id """), {"kid": kid}) row = result.fetchone() allergy_is_new = row is not None if row: allergy_cat_id = row[0] else: r = await conn.execute(text( "SELECT id FROM food_flag_categories WHERE kitchen_id = :kid AND name = 'Allergy'" ), {"kid": kid}) allergy_cat_id = r.fetchone()[0] # Create Dietary category (only seed flags if category is new) result = await conn.execute(text(""" INSERT INTO food_flag_categories (kitchen_id, name, propagation_type, sort_order, created_at) VALUES (:kid, 'Dietary', 'suitable_for', 1, NOW()) ON CONFLICT (kitchen_id, name) DO NOTHING RETURNING id """), {"kid": kid}) row = result.fetchone() dietary_is_new = row is not None if row: dietary_cat_id = row[0] else: r = await conn.execute(text( "SELECT id FROM food_flag_categories WHERE kitchen_id = :kid AND name = 'Dietary'" ), {"kid": kid}) dietary_cat_id = r.fetchone()[0] # Only seed flags for newly created categories (don't re-insert user-deleted flags) if allergy_is_new: for i, (name, code, icon) in enumerate(allergy_flags): await conn.execute(text(""" INSERT INTO food_flags (category_id, kitchen_id, name, code, icon, sort_order, created_at) VALUES (:cat_id, :kid, :name, :code, :icon, :sort, NOW()) ON CONFLICT (kitchen_id, name) DO NOTHING """), {"cat_id": allergy_cat_id, "kid": kid, "name": name, "code": code, "icon": icon, "sort": i}) if dietary_is_new: for i, (name, code, icon) in enumerate(dietary_flags): await conn.execute(text(""" INSERT INTO food_flags (category_id, kitchen_id, name, code, icon, sort_order, created_at) VALUES (:cat_id, :kid, :name, :code, :icon, :sort, NOW()) ON CONFLICT (kitchen_id, name) DO NOTHING """), {"cat_id": dietary_cat_id, "kid": kid, "name": name, "code": code, "icon": icon, "sort": i}) print(f"+ Pre-seeded food flag categories and flags for {len(kitchen_ids)} kitchen(s)") except Exception as e: print(f"! Warning seeding food flags: {e}") if __name__ == "__main__": print("Running migration: add_recipe_system") asyncio.run(migrate()) print("Migration complete!")