""" GraphQL client module for communicating with the publishing GraphQL API. """ from typing import Dict, Any, Optional, List, Tuple, AsyncGenerator import os import asyncio import aiohttp from dataclasses import dataclass from dotenv import load_dotenv # Load environment variables load_dotenv() @dataclass class UpdateResult: """Result of a single delivery date update operation.""" pub_song_id: int success: bool error_message: Optional[str] = None response_data: Optional[Dict[str, Any]] = None @dataclass class BatchResult: """Result of a batch of delivery date updates.""" successful_updates: int failed_updates: int results: List[UpdateResult] @property def total_updates(self) -> int: return self.successful_updates + self.failed_updates @property def success_rate(self) -> float: if self.total_updates == 0: return 0.0 return self.successful_updates / self.total_updates # GraphQL mutation extracted from delivery-updater.ts UPDATE_DELIVERY_DATE_MUTATION = """ mutation updateDeliveryDate($pubSongId: Int!, $deliveryDate: String!) { updatePublishingCompositionsDeliveryDate( pubSongId: $pubSongId deliveryDate: $deliveryDate ) { id } } """ class GraphQLClient: """Client for interacting with the Publishing GraphQL API.""" def __init__( self, environment: str = "qa", user_config: Optional[Dict[str, str]] = None ): """ Initialize the GraphQL client. Args: environment: Either "qa" or "prod" user_config: User configuration dict with 'identity_id' and 'profile_id' keys """ self.environment = environment.lower() self.user_config = user_config self.base_url = self._get_graphql_url() self.headers = self._get_headers() self.chunk_size = int(os.getenv("CHUNK_SIZE", "200")) self.chunk_delay_ms = int(os.getenv("CHUNK_DELAY_MS", "10")) def _get_graphql_url(self) -> str: """Get the GraphQL URL for the current environment.""" if self.environment == "qa": url = os.getenv("QA_GRAPHQL_URL") elif self.environment == "prod": url = os.getenv("PROD_GRAPHQL_URL") else: raise ValueError(f"Unsupported environment: {self.environment}") if not url: raise ValueError( f"GraphQL URL not configured for environment: {self.environment}" ) return url def _get_headers(self) -> Dict[str, str]: """Get the required headers for GraphQL requests.""" if self.user_config: # Use user-selected configuration profile_id = self.user_config.get("profile_id") identity_id = self.user_config.get("identity_id") else: # Fall back to environment variables profile_id = os.getenv("ORCHARD_PROFILE_ID") identity_id = os.getenv("ORCHARD_IDENTITY_ID") profile_type = os.getenv("ORCHARD_PROFILE_TYPE", "PublishingProfile") if not profile_id or not identity_id: raise ValueError("Required authentication headers not configured") return { "Content-Type": "application/json", "Orchard-Profile-Id": str(profile_id), "Orchard-Profile-UUID": "delivery-updater-script-streamlit", "Orchard-Profile-Type": profile_type, "Orchard-Identity-Id": identity_id, "apollographql-client-name": "delivery-updater-streamlit-app", } async def update_delivery_date( self, session: aiohttp.ClientSession, pub_song_id: int, delivery_date: str ) -> UpdateResult: """ Update the delivery date for a single composition. Args: session: The aiohttp client session pub_song_id: The publishing song ID delivery_date: The delivery date in YYYY-MM-DD HH:MM format Returns: UpdateResult: The result of the update operation """ variables = {"pubSongId": pub_song_id, "deliveryDate": delivery_date} payload = {"query": UPDATE_DELIVERY_DATE_MUTATION, "variables": variables} try: async with session.post( self.base_url, json=payload, headers=self.headers, timeout=aiohttp.ClientTimeout(total=30), ) as response: if response.status != 200: error_text = await response.text() return UpdateResult( pub_song_id=pub_song_id, success=False, error_message=f"HTTP {response.status}: {error_text}", ) response_data = await response.json() # Check for GraphQL errors if "errors" in response_data: errors = response_data["errors"] error_messages = [] for error in errors: error_messages.append( error.get("message", "Unknown GraphQL error") ) # Include validation errors if present if ( "extensions" in error and "validationErrors" in error["extensions"] ): validation_errors = error["extensions"]["validationErrors"] error_messages.extend([str(ve) for ve in validation_errors]) return UpdateResult( pub_song_id=pub_song_id, success=False, error_message="; ".join(error_messages), response_data=response_data, ) # Success case return UpdateResult( pub_song_id=pub_song_id, success=True, response_data=response_data ) except asyncio.TimeoutError: return UpdateResult( pub_song_id=pub_song_id, success=False, error_message="Request timeout" ) except Exception as e: return UpdateResult( pub_song_id=pub_song_id, success=False, error_message=f"Unexpected error: {str(e)}", ) async def update_delivery_dates_batch( self, compositions: List[Dict[str, Any]], progress_callback: Optional[callable] = None, ) -> AsyncGenerator[Tuple[int, BatchResult], None]: """ Update delivery dates for multiple compositions in batches. Args: compositions: List of dicts with 'pubSongId' and 'deliveryDate' keys progress_callback: Optional callback function called with (completed, total) counts Yields: Tuple[int, BatchResult]: (batch_number, batch_result) for each completed batch """ if not compositions: return # Split into chunks to match the existing script behavior chunks = [ compositions[i : i + self.chunk_size] for i in range(0, len(compositions), self.chunk_size) ] total_processed = 0 total_compositions = len(compositions) async with aiohttp.ClientSession() as session: for batch_number, chunk in enumerate(chunks): # Process all items in the chunk concurrently tasks = [ self.update_delivery_date( session, comp["pubSongId"], comp["deliveryDate"] ) for comp in chunk ] # Wait for all tasks in the batch to complete results = await asyncio.gather(*tasks, return_exceptions=True) # Process results and handle any exceptions batch_results = [] successful = 0 failed = 0 for result in results: if isinstance(result, Exception): # Handle cases where the task itself raised an exception batch_results.append( UpdateResult( pub_song_id=-1, # Unknown pub_song_id success=False, error_message=f"Task exception: {str(result)}", ) ) failed += 1 elif isinstance(result, UpdateResult): batch_results.append(result) if result.success: successful += 1 else: failed += 1 total_processed += len(chunk) # Call progress callback if provided if progress_callback: progress_callback(total_processed, total_compositions) # Yield the batch result yield ( batch_number, BatchResult( successful_updates=successful, failed_updates=failed, results=batch_results, ), ) # Add delay between chunks (matching the original script) if batch_number < len(chunks) - 1: # Don't delay after the last chunk await asyncio.sleep(self.chunk_delay_ms / 1000.0) def get_environment_display_name(self) -> str: """Get a human-readable name for the current environment.""" return self.environment.upper() def validate_configuration(self) -> List[str]: """ Validate that all required configuration is present. Returns: List[str]: List of validation error messages (empty if valid) """ errors = [] # Check authentication configuration if self.user_config: # Validate user-provided configuration if not self.user_config.get("profile_id"): errors.append("Missing profile_id in user configuration") if not self.user_config.get("identity_id"): errors.append("Missing identity_id in user configuration") else: # Check environment variables as fallback required_env_vars = ["ORCHARD_PROFILE_ID", "ORCHARD_IDENTITY_ID"] for var in required_env_vars: if not os.getenv(var): errors.append(f"Missing environment variable: {var}") # Check environment-specific URLs url_var = f"{self.environment.upper()}_GRAPHQL_URL" if not os.getenv(url_var): errors.append(f"Missing environment variable: {url_var}") # Validate chunk size try: chunk_size = int(os.getenv("CHUNK_SIZE", "200")) if chunk_size <= 0: errors.append("CHUNK_SIZE must be a positive integer") except ValueError: errors.append("CHUNK_SIZE must be a valid integer") return errors def create_client( environment: str, user_config: Optional[Dict[str, str]] = None ) -> GraphQLClient: """ Factory function to create a GraphQL client for the specified environment. Args: environment: Either "qa" or "prod" user_config: User configuration dict with 'identity_id' and 'profile_id' keys Returns: GraphQLClient: Configured client instance Raises: ValueError: If configuration is invalid """ client = GraphQLClient(environment, user_config) config_errors = client.validate_configuration() if config_errors: error_msg = "GraphQL client configuration errors:\n" + "\n".join( f"- {error}" for error in config_errors ) raise ValueError(error_msg) return client