#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Demo Recipes Seeding Script for Recipes Service Creates realistic Spanish recipes for demo template tenants This script runs as a Kubernetes init job inside the recipes-service container. It populates the template tenants with a comprehensive catalog of recipes using pre-defined UUIDs. Usage: python /app/scripts/demo/seed_demo_recipes.py Environment Variables Required: RECIPES_DATABASE_URL - PostgreSQL connection string for recipes database DEMO_MODE - Set to 'production' for production seeding LOG_LEVEL - Logging level (default: INFO) """ import asyncio import uuid import sys import os import json from datetime import datetime, timezone, timedelta from pathlib import Path import random # Add app to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy import select import structlog from app.models.recipes import ( Recipe, RecipeIngredient, ProductionBatch, RecipeStatus, ProductionStatus, ProductionPriority, MeasurementUnit ) # Configure logging structlog.configure( processors=[ structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.dev.ConsoleRenderer() ] ) logger = structlog.get_logger() # Fixed Demo Tenant IDs (must match tenant service) DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") def load_recipes_data(): """Load recipes data from JSON file""" # Look for data file in the same directory as this script data_file = Path(__file__).parent / "recetas_es.json" if not data_file.exists(): raise FileNotFoundError( f"Recipes data file not found: {data_file}. " "Make sure recetas_es.json is in the same directory as this script." ) logger.info("Loading recipes data", file=str(data_file)) with open(data_file, 'r', encoding='utf-8') as f: data = json.load(f) recipes = data.get("recetas", []) logger.info(f"Loaded {len(recipes)} recipes from JSON") return recipes async def seed_recipes_for_tenant( db: AsyncSession, tenant_id: uuid.UUID, tenant_name: str, recipes_data: list ) -> dict: """ Seed recipes for a specific tenant using pre-defined UUIDs Args: db: Database session tenant_id: UUID of the tenant tenant_name: Name of the tenant (for logging) recipes_data: List of recipe dictionaries with pre-defined IDs Returns: Dict with seeding statistics """ logger.info("─" * 80) logger.info(f"Seeding recipes for: {tenant_name}") logger.info(f"Tenant ID: {tenant_id}") logger.info("─" * 80) created_recipes = 0 skipped_recipes = 0 created_ingredients = 0 created_batches = 0 for recipe_data in recipes_data: recipe_name = recipe_data["name"] # Generate tenant-specific UUIDs (same approach as inventory) base_recipe_id = uuid.UUID(recipe_data["id"]) base_product_id = uuid.UUID(recipe_data["finished_product_id"]) tenant_int = int(tenant_id.hex, 16) recipe_id = uuid.UUID(int=tenant_int ^ int(base_recipe_id.hex, 16)) finished_product_id = uuid.UUID(int=tenant_int ^ int(base_product_id.hex, 16)) # Check if recipe already exists result = await db.execute( select(Recipe).where( Recipe.tenant_id == tenant_id, Recipe.id == recipe_id ) ) existing_recipe = result.scalars().first() if existing_recipe: logger.debug(f" ⏭️ Skipping recipe (exists): {recipe_name}") skipped_recipes += 1 continue # Create recipe using pre-defined UUID recipe = Recipe( id=recipe_id, tenant_id=tenant_id, name=recipe_name, recipe_code=f"REC-{created_recipes + 1:03d}", version="1.0", finished_product_id=finished_product_id, description=recipe_data.get("description"), category=recipe_data.get("category"), cuisine_type=recipe_data.get("cuisine_type"), difficulty_level=recipe_data.get("difficulty_level", 1), yield_quantity=recipe_data.get("yield_quantity"), yield_unit=MeasurementUnit(recipe_data.get("yield_unit", "units")), prep_time_minutes=recipe_data.get("prep_time_minutes"), cook_time_minutes=recipe_data.get("cook_time_minutes"), total_time_minutes=recipe_data.get("total_time_minutes"), rest_time_minutes=recipe_data.get("rest_time_minutes"), instructions=recipe_data.get("instructions"), preparation_notes=recipe_data.get("preparation_notes"), storage_instructions=recipe_data.get("storage_instructions"), quality_standards=recipe_data.get("quality_standards"), status=RecipeStatus.ACTIVE, is_seasonal=recipe_data.get("is_seasonal", False), is_signature_item=recipe_data.get("is_signature_item", False), created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) db.add(recipe) created_recipes += 1 logger.debug(f" ✅ Created recipe: {recipe_name}") # Create recipe ingredients using tenant-specific ingredient IDs for ing_data in recipe_data.get("ingredientes", []): base_ingredient_id = uuid.UUID(ing_data["ingredient_id"]) ingredient_id = uuid.UUID(int=tenant_int ^ int(base_ingredient_id.hex, 16)) # Parse unit unit_str = ing_data.get("unit", "g") try: unit = MeasurementUnit(unit_str) except ValueError: logger.warning(f" ⚠️ Invalid unit: {unit_str}, using GRAMS") unit = MeasurementUnit.GRAMS recipe_ingredient = RecipeIngredient( id=uuid.uuid4(), tenant_id=tenant_id, recipe_id=recipe_id, ingredient_id=ingredient_id, quantity=ing_data["quantity"], unit=unit, preparation_method=ing_data.get("preparation_method"), ingredient_order=ing_data.get("ingredient_order", 1), ingredient_group=ing_data.get("ingredient_group") ) db.add(recipe_ingredient) created_ingredients += 1 # Create some sample production batches (historical data) num_batches = random.randint(3, 8) for i in range(num_batches): # Random date in the past 30 days days_ago = random.randint(1, 30) production_date = datetime.now(timezone.utc) - timedelta(days=days_ago) # Random multiplier and quantity multiplier = random.choice([0.5, 1.0, 1.5, 2.0]) planned_qty = recipe_data.get("yield_quantity", 10) * multiplier actual_qty = planned_qty * random.uniform(0.95, 1.05) batch = ProductionBatch( id=uuid.uuid4(), tenant_id=tenant_id, recipe_id=recipe_id, batch_number=f"BATCH-{tenant_id.hex[:8].upper()}-{i+1:04d}", production_date=production_date, planned_quantity=planned_qty, actual_quantity=actual_qty, yield_percentage=(actual_qty / planned_qty * 100) if planned_qty > 0 else 100, batch_size_multiplier=multiplier, status=ProductionStatus.COMPLETED, priority=ProductionPriority.NORMAL, quality_score=random.uniform(7.5, 9.5), created_at=production_date, updated_at=production_date ) db.add(batch) created_batches += 1 # Commit all changes for this tenant await db.commit() logger.info(f" 📊 Recipes: {created_recipes}, Ingredients: {created_ingredients}, Batches: {created_batches}") logger.info("") return { "tenant_id": str(tenant_id), "tenant_name": tenant_name, "recipes_created": created_recipes, "recipes_skipped": skipped_recipes, "recipe_ingredients_created": created_ingredients, "production_batches_created": created_batches, "total_recipes": len(recipes_data) } async def seed_recipes(db: AsyncSession): """ Seed recipes for all demo template tenants Args: db: Database session Returns: Dict with overall seeding statistics """ logger.info("=" * 80) logger.info("📚 Starting Demo Recipes Seeding") logger.info("=" * 80) # Load recipes data once try: recipes_data = load_recipes_data() except FileNotFoundError as e: logger.error(str(e)) raise results = [] # Seed for San Pablo (Traditional Bakery) logger.info("") result_san_pablo = await seed_recipes_for_tenant( db, DEMO_TENANT_SAN_PABLO, "Panadería San Pablo (Traditional)", recipes_data ) results.append(result_san_pablo) # Seed for La Espiga (Central Workshop) result_la_espiga = await seed_recipes_for_tenant( db, DEMO_TENANT_LA_ESPIGA, "Panadería La Espiga (Central Workshop)", recipes_data ) results.append(result_la_espiga) # Calculate totals total_recipes = sum(r["recipes_created"] for r in results) total_ingredients = sum(r["recipe_ingredients_created"] for r in results) total_batches = sum(r["production_batches_created"] for r in results) total_skipped = sum(r["recipes_skipped"] for r in results) logger.info("=" * 80) logger.info("✅ Demo Recipes Seeding Completed") logger.info("=" * 80) return { "service": "recipes", "tenants_seeded": len(results), "total_recipes_created": total_recipes, "total_recipe_ingredients_created": total_ingredients, "total_production_batches_created": total_batches, "total_skipped": total_skipped, "results": results } async def main(): """Main execution function""" logger.info("Demo Recipes Seeding Script Starting") logger.info("Mode: %s", os.getenv("DEMO_MODE", "development")) logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO")) # Get database URLs from environment database_url = os.getenv("RECIPES_DATABASE_URL") or os.getenv("DATABASE_URL") if not database_url: logger.error("❌ RECIPES_DATABASE_URL or DATABASE_URL environment variable must be set") return 1 # Convert to async URL if needed if database_url.startswith("postgresql://"): database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) logger.info("Connecting to recipes database") # Create engine and session engine = create_async_engine( database_url, echo=False, pool_pre_ping=True, pool_size=5, max_overflow=10 ) session_maker = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False ) try: async with session_maker() as session: result = await seed_recipes(session) logger.info("") logger.info("📊 Seeding Summary:") logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}") logger.info(f" ✅ Recipes created: {result['total_recipes_created']}") logger.info(f" ✅ Recipe ingredients: {result['total_recipe_ingredients_created']}") logger.info(f" ✅ Production batches: {result['total_production_batches_created']}") logger.info(f" ⏭️ Skipped: {result['total_skipped']}") logger.info("") # Print per-tenant details for tenant_result in result['results']: logger.info( f" {tenant_result['tenant_name']}: " f"{tenant_result['recipes_created']} recipes, " f"{tenant_result['recipe_ingredients_created']} ingredients, " f"{tenant_result['production_batches_created']} batches" ) logger.info("") logger.info("🎉 Success! Recipe catalog is ready for cloning.") logger.info("") logger.info("Recipes created:") logger.info(" • Baguette Francesa Tradicional") logger.info(" • Croissant de Mantequilla Artesanal") logger.info(" • Pan de Pueblo con Masa Madre") logger.info(" • Napolitana de Chocolate") logger.info("") logger.info("Note: All IDs are pre-defined and hardcoded for cross-service consistency") logger.info("") return 0 except Exception as e: logger.error("=" * 80) logger.error("❌ Demo Recipes Seeding Failed") logger.error("=" * 80) logger.error("Error: %s", str(e)) logger.error("", exc_info=True) return 1 finally: await engine.dispose() if __name__ == "__main__": exit_code = asyncio.run(main()) sys.exit(exit_code)