"""Recipe Service - Business Logic for Recipe Operations. Handles recipe CRUD operations, search, and filtering. """ import logging from typing import List, Optional from src.database.models import Recipe logger = logging.getLogger(__name__) class RecipeService: """Service class for recipe business logic. This service layer provides a clean interface for UI components to interact with recipe data without directly accessing the database layer. """ def __init__(self, db_manager): """Initialize the recipe service. Args: db_manager: DatabaseManager instance. """ self.db_manager = db_manager def create_recipe(self, name: str, prompt_text: str, **kwargs) -> Optional[Recipe]: """Create a new recipe. Args: name: Recipe name (must be unique). prompt_text: Prompt template text. **kwargs: Additional optional fields (tags, description, notes). Returns: Recipe instance if successful, None otherwise. """ try: # Validate name is not empty if not name or not name.strip(): logger.error("Cannot create recipe with empty name") return None # Validate prompt_text is not empty if not prompt_text or not prompt_text.strip(): logger.error("Cannot create recipe with empty prompt text") return None # Create recipe using model recipe = Recipe.create( self.db_manager, name=name.strip(), prompt_text=prompt_text, tags=kwargs.get('tags', ''), description=kwargs.get('description', ''), notes=kwargs.get('notes', '') ) if recipe: logger.info(f"Created recipe: {recipe.name} (ID: {recipe.id})") else: logger.error(f"Failed to create recipe: {name} (likely duplicate name)") return recipe except Exception as e: logger.error(f"Error creating recipe: {e}", exc_info=True) return None def update_recipe(self, recipe_id: int, **kwargs) -> bool: """Update an existing recipe. Args: recipe_id: ID of recipe to update. **kwargs: Fields to update (name, prompt_text, tags, description, notes). Returns: bool: True if successful, False otherwise. """ try: # Validate at least one field to update if not kwargs: logger.warning("No fields provided to update") return False # Validate name if provided if 'name' in kwargs: if not kwargs['name'] or not kwargs['name'].strip(): logger.error("Cannot update recipe with empty name") return False kwargs['name'] = kwargs['name'].strip() # Validate prompt_text if provided if 'prompt_text' in kwargs: if not kwargs['prompt_text'] or not kwargs['prompt_text'].strip(): logger.error("Cannot update recipe with empty prompt text") return False # Update recipe using model success = Recipe.update(self.db_manager, recipe_id, **kwargs) if success: logger.info(f"Updated recipe ID {recipe_id}") else: logger.error(f"Failed to update recipe ID {recipe_id}") return success except Exception as e: logger.error(f"Error updating recipe {recipe_id}: {e}", exc_info=True) return False def delete_recipe(self, recipe_id: int) -> bool: """Delete a recipe. Args: recipe_id: ID of recipe to delete. Returns: bool: True if successful, False otherwise. """ try: # Delete recipe using model (CASCADE will delete outputs) success = Recipe.delete(self.db_manager, recipe_id) if success: logger.info(f"Deleted recipe ID {recipe_id}") else: logger.warning(f"Recipe ID {recipe_id} not found for deletion") return success except Exception as e: logger.error(f"Error deleting recipe {recipe_id}: {e}", exc_info=True) return False def get_recipe(self, recipe_id: int) -> Optional[Recipe]: """Get a recipe by ID. Args: recipe_id: Recipe ID. Returns: Recipe instance or None. """ try: recipe = Recipe.get_by_id(self.db_manager, recipe_id) if recipe: logger.debug(f"Retrieved recipe ID {recipe_id}: {recipe.name}") else: logger.debug(f"Recipe ID {recipe_id} not found") return recipe except Exception as e: logger.error(f"Error retrieving recipe {recipe_id}: {e}", exc_info=True) return None def get_all_recipes(self) -> List[Recipe]: """Get all recipes. Returns: List of all recipes. """ try: recipes = Recipe.get_all(self.db_manager) logger.debug(f"Retrieved {len(recipes)} recipes") return recipes except Exception as e: logger.error(f"Error retrieving all recipes: {e}", exc_info=True) return [] def search_recipes(self, query: str) -> List[Recipe]: """Search recipes by name (case-insensitive). Args: query: Search query string. Returns: List of matching recipes. """ try: if not query or not query.strip(): # Empty query returns all recipes return self.get_all_recipes() recipes = Recipe.search(self.db_manager, query=query.strip()) logger.debug(f"Search '{query}' found {len(recipes)} recipes") return recipes except Exception as e: logger.error(f"Error searching recipes: {e}", exc_info=True) return [] def filter_by_tags(self, tags: List[str]) -> List[Recipe]: """Filter recipes by tags. Args: tags: List of tags to filter by. Returns: List of recipes matching any of the tags. """ try: if not tags: # No tags returns all recipes return self.get_all_recipes() recipes = Recipe.search(self.db_manager, tags=tags) logger.debug(f"Filter by tags {tags} found {len(recipes)} recipes") return recipes except Exception as e: logger.error(f"Error filtering recipes by tags: {e}", exc_info=True) return [] def search_and_filter(self, query: str, tags: List[str]) -> List[Recipe]: """Search and filter recipes simultaneously. Args: query: Search query string. tags: List of tags to filter by. Returns: List of recipes matching both criteria. """ try: # Use model's search method with both parameters recipes = Recipe.search( self.db_manager, query=query.strip() if query else "", tags=tags if tags else [] ) logger.debug(f"Search+filter ('{query}', {tags}) found {len(recipes)} recipes") return recipes except Exception as e: logger.error(f"Error searching and filtering recipes: {e}", exc_info=True) return [] def check_name_unique(self, name: str, exclude_id: Optional[int] = None) -> bool: """Check if a recipe name is unique. Args: name: Recipe name to check. exclude_id: Optional recipe ID to exclude from check (for updates). Returns: bool: True if name is unique, False if already exists. """ try: if not name or not name.strip(): return False # Search for recipes with this exact name all_recipes = self.get_all_recipes() for recipe in all_recipes: if recipe.name.lower() == name.strip().lower(): # If we're excluding an ID (for updates), ignore that recipe if exclude_id is not None and recipe.id == exclude_id: continue # Name already exists return False return True except Exception as e: logger.error(f"Error checking name uniqueness: {e}", exc_info=True) return False