diff --git a/UNDER-OBSERVATION.md b/UNDER-OBSERVATION.md index bd221155..bad1ced9 100644 --- a/UNDER-OBSERVATION.md +++ b/UNDER-OBSERVATION.md @@ -101,10 +101,18 @@ The logout was NOT caused by backend issues or container restarts. It was caused **RESOLVED** - Auth state stable, backend permissions correct, useLocation fix preserved. **ADDITIONAL FIX (Dec 10, 2025 - Evening):** -- Fixed image generation task progress polling 403 errors -- Root cause: `IsSystemAccountOrDeveloper` was still in class-level permissions -- Solution: Moved to `get_permissions()` method to allow action-level overrides -- `task_progress` and `get_image_generation_settings` now accessible to all authenticated users -- Save/test operations still restricted to system accounts +1. **Permission Fix**: Fixed image generation task progress polling 403 errors + - Root cause: `IsSystemAccountOrDeveloper` was still in class-level permissions + - Solution: Moved to `get_permissions()` method to allow action-level overrides + - `task_progress` and `get_image_generation_settings` now accessible to all authenticated users + - Save/test operations still restricted to system accounts -**Monitor for 48 hours** - Watch for any recurrence of useLocation errors or auth issues after container restarts. +2. **System Account Fallback**: Fixed "Image generation settings not found" for normal users + - Root cause: IntegrationSettings are account-specific - normal users don't have their own settings + - Only super user account (aws-admin) has configured API keys + - Solution: Added fallback to system account (aws-admin) settings in `process_image_generation_queue` task + - When user's account doesn't have IntegrationSettings, falls back to system account + - Allows normal users to use centralized API keys managed by super users + - Files modified: `backend/igny8_core/ai/tasks.py` + +**Monitor for 48 hours** - Watch for any recurrence of useLocation errors or auth issues after container restarts. Test image generation with normal user accounts (paid-2). diff --git a/backend/igny8_core/ai/tasks.py b/backend/igny8_core/ai/tasks.py index a770f6a9..e5f62ac3 100644 --- a/backend/igny8_core/ai/tasks.py +++ b/backend/igny8_core/ai/tasks.py @@ -182,6 +182,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None results = [] # Get image generation settings from IntegrationSettings + # Normal users use system account settings (aws-admin) via fallback logger.info("[process_image_generation_queue] Step 1: Loading image generation settings") try: image_settings = IntegrationSettings.objects.get( @@ -189,41 +190,56 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None integration_type='image_generation', is_active=True ) + logger.info(f"[process_image_generation_queue] Image generation settings found for account {account.id}") config = image_settings.config or {} - logger.info(f"[process_image_generation_queue] Image generation settings found. Config keys: {list(config.keys())}") - logger.info(f"[process_image_generation_queue] Full config: {config}") - - # Get provider and model from config (respect user settings) - provider = config.get('provider', 'openai') - # Get model - try 'model' first, then 'imageModel' as fallback - model = config.get('model') or config.get('imageModel') or 'dall-e-3' - logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings") - image_type = config.get('image_type', 'realistic') - image_format = config.get('image_format', 'webp') - desktop_enabled = config.get('desktop_enabled', True) - mobile_enabled = config.get('mobile_enabled', True) - # Get image sizes from config, with fallback defaults - featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024') - desktop_image_size = config.get('desktop_image_size') or '1024x1024' - in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512 - - logger.info(f"[process_image_generation_queue] Settings loaded:") - logger.info(f" - Provider: {provider}") - logger.info(f" - Model: {model}") - logger.info(f" - Image type: {image_type}") - logger.info(f" - Image format: {image_format}") - logger.info(f" - Desktop enabled: {desktop_enabled}") - logger.info(f" - Mobile enabled: {mobile_enabled}") except IntegrationSettings.DoesNotExist: - logger.error("[process_image_generation_queue] ERROR: Image generation settings not found") - logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}, integration_type: 'image_generation'") - return {'success': False, 'error': 'Image generation settings not found'} + # Fallback to system account (aws-admin) settings + logger.info(f"[process_image_generation_queue] No settings for account {account.id}, falling back to system account") + from igny8_core.auth.models import Account + try: + system_account = Account.objects.get(slug='aws-admin') + image_settings = IntegrationSettings.objects.get( + account=system_account, + integration_type='image_generation', + is_active=True + ) + logger.info(f"[process_image_generation_queue] Using system account (aws-admin) settings") + config = image_settings.config or {} + except (Account.DoesNotExist, IntegrationSettings.DoesNotExist): + logger.error("[process_image_generation_queue] ERROR: Image generation settings not found in system account either") + return {'success': False, 'error': 'Image generation settings not found'} except Exception as e: logger.error(f"[process_image_generation_queue] ERROR loading image generation settings: {e}", exc_info=True) return {'success': False, 'error': f'Error loading image generation settings: {str(e)}'} + logger.info(f"[process_image_generation_queue] Image generation settings loaded. Config keys: {list(config.keys())}") + logger.info(f"[process_image_generation_queue] Full config: {config}") + + # Get provider and model from config (respect user settings) + provider = config.get('provider', 'openai') + # Get model - try 'model' first, then 'imageModel' as fallback + model = config.get('model') or config.get('imageModel') or 'dall-e-3' + logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings") + image_type = config.get('image_type', 'realistic') + image_format = config.get('image_format', 'webp') + desktop_enabled = config.get('desktop_enabled', True) + mobile_enabled = config.get('mobile_enabled', True) + # Get image sizes from config, with fallback defaults + featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024') + desktop_image_size = config.get('desktop_image_size') or '1024x1024' + in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512 + + logger.info(f"[process_image_generation_queue] Settings loaded:") + logger.info(f" - Provider: {provider}") + logger.info(f" - Model: {model}") + logger.info(f" - Image type: {image_type}") + logger.info(f" - Image format: {image_format}") + logger.info(f" - Desktop enabled: {desktop_enabled}") + logger.info(f" - Mobile enabled: {mobile_enabled}") + # Get provider API key (using same approach as test image generation) # Note: API key is stored as 'apiKey' (camelCase) in IntegrationSettings.config + # Normal users use system account settings (aws-admin) via fallback logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key") try: provider_settings = IntegrationSettings.objects.get( @@ -231,26 +247,39 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None integration_type=provider, # Use the provider from settings is_active=True ) - logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found") - logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}") - - api_key = provider_settings.config.get('apiKey') if provider_settings.config else None - if not api_key: - logger.error(f"[process_image_generation_queue] {provider.upper()} API key not found in config") - logger.error(f"[process_image_generation_queue] {provider.upper()} config: {provider_settings.config}") - return {'success': False, 'error': f'{provider.upper()} API key not configured'} - - # Log API key presence (but not the actual key for security) - api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***" - logger.info(f"[process_image_generation_queue] {provider.upper()} API key retrieved successfully (length: {len(api_key)}, preview: {api_key_preview})") + logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found for account {account.id}") except IntegrationSettings.DoesNotExist: - logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found") - logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}, integration_type: '{provider}'") - return {'success': False, 'error': f'{provider.upper()} integration not found or not active'} + # Fallback to system account (aws-admin) settings + logger.info(f"[process_image_generation_queue] No {provider.upper()} settings for account {account.id}, falling back to system account") + from igny8_core.auth.models import Account + try: + system_account = Account.objects.get(slug='aws-admin') + provider_settings = IntegrationSettings.objects.get( + account=system_account, + integration_type=provider, + is_active=True + ) + logger.info(f"[process_image_generation_queue] Using system account (aws-admin) {provider.upper()} settings") + except (Account.DoesNotExist, IntegrationSettings.DoesNotExist): + logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found in system account either") + return {'success': False, 'error': f'{provider.upper()} integration not found or not active'} except Exception as e: logger.error(f"[process_image_generation_queue] ERROR getting {provider.upper()} API key: {e}", exc_info=True) return {'success': False, 'error': f'Error retrieving {provider.upper()} API key: {str(e)}'} + # Extract API key from provider settings + logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}") + + api_key = provider_settings.config.get('apiKey') if provider_settings.config else None + if not api_key: + logger.error(f"[process_image_generation_queue] {provider.upper()} API key not found in config") + logger.error(f"[process_image_generation_queue] {provider.upper()} config: {provider_settings.config}") + return {'success': False, 'error': f'{provider.upper()} API key not configured'} + + # Log API key presence (but not the actual key for security) + api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***" + logger.info(f"[process_image_generation_queue] {provider.upper()} API key retrieved successfully (length: {len(api_key)}, preview: {api_key_preview})") + # Get image prompt template (has placeholders: {image_type}, {post_title}, {image_prompt}) try: image_prompt_template = PromptRegistry.get_image_prompt_template(account) diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index 3df4a624..b7762349 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -838,7 +838,9 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): @action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings') def get_image_generation_settings(self, request): - """Get image generation settings for current account""" + """Get image generation settings for current account + Normal users fallback to system account (aws-admin) settings + """ account = getattr(request, 'account', None) if not account: @@ -863,11 +865,44 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): try: from .models import IntegrationSettings - integration = IntegrationSettings.objects.get( - account=account, - integration_type='image_generation', - is_active=True - ) + from igny8_core.auth.models import Account + + # Try to get settings for user's account first + try: + integration = IntegrationSettings.objects.get( + account=account, + integration_type='image_generation', + is_active=True + ) + logger.info(f"[get_image_generation_settings] Found settings for account {account.id}") + except IntegrationSettings.DoesNotExist: + # Fallback to system account (aws-admin) settings - normal users use centralized settings + logger.info(f"[get_image_generation_settings] No settings for account {account.id}, falling back to system account") + try: + system_account = Account.objects.get(slug='aws-admin') + integration = IntegrationSettings.objects.get( + account=system_account, + integration_type='image_generation', + is_active=True + ) + logger.info(f"[get_image_generation_settings] Using system account (aws-admin) settings") + except (Account.DoesNotExist, IntegrationSettings.DoesNotExist): + logger.error("[get_image_generation_settings] No image generation settings found in system account either") + # Return default settings instead of error + return success_response( + data={ + 'config': { + 'provider': 'openai', + 'model': 'dall-e-3', + 'image_type': 'realistic', + 'max_in_article_images': 2, + 'image_format': 'webp', + 'desktop_enabled': True, + 'mobile_enabled': True, + } + }, + request=request + ) config = integration.config or {} diff --git a/integration_views.py b/integration_views.py new file mode 100644 index 00000000..3e986ee9 --- /dev/null +++ b/integration_views.py @@ -0,0 +1,1408 @@ +""" +Integration settings views - for OpenAI, Runware, GSC integrations +Unified API Standard v1.0 compliant +""" +import logging +from rest_framework import viewsets, status +from rest_framework.decorators import action +from django.db import transaction +from drf_spectacular.utils import extend_schema, extend_schema_view +from igny8_core.api.base import AccountModelViewSet +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper +from django.conf import settings + +logger = logging.getLogger(__name__) + + +@extend_schema_view( + list=extend_schema(tags=['System']), + retrieve=extend_schema(tags=['System']), + update=extend_schema(tags=['System']), + test_connection=extend_schema(tags=['System']), + task_progress=extend_schema(tags=['System']), + get_image_generation_settings=extend_schema(tags=['System']), +) +class IntegrationSettingsViewSet(viewsets.ViewSet): + """ + ViewSet for managing integration settings (OpenAI, Runware, GSC) + Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings + We store in IntegrationSettings model with account isolation + + IMPORTANT: Integration settings are system-wide (configured by super users/developers) + Normal users don't configure their own API keys - they use the system account settings via fallback + + NOTE: Class-level permissions are [IsAuthenticatedAndActive, HasTenantAccess] only. + Individual actions override with IsSystemAccountOrDeveloper where needed (save, test). + task_progress and get_image_generation_settings need to be accessible to all authenticated users. + """ + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] + + throttle_scope = 'system_admin' + throttle_classes = [DebugScopedRateThrottle] + + def list(self, request): + """List all integrations - for debugging URL patterns""" + logger.info("[IntegrationSettingsViewSet] list() called") + return success_response( + data={ + 'message': 'IntegrationSettingsViewSet is working', + 'available_endpoints': [ + 'GET /api/v1/system/settings/integrations//', + 'POST /api/v1/system/settings/integrations//save/', + 'POST /api/v1/system/settings/integrations//test/', + 'POST /api/v1/system/settings/integrations//generate/', + ] + }, + request=request + ) + + def retrieve(self, request, pk=None): + """Get integration settings - GET /api/v1/system/settings/integrations/{pk}/""" + return self.get_settings(request, pk) + + def update(self, request, pk=None): + """Save integration settings (PUT) - PUT /api/v1/system/settings/integrations/{pk}/""" + return self.save_settings(request, pk) + + def save_post(self, request, pk=None, **kwargs): + """Save integration settings (POST) - POST /api/v1/system/settings/integrations/{pk}/save/ + This matches the frontend endpoint call exactly. + Reference plugin: WordPress form submits to options.php which calls update_option() via register_setting callback. + We save to IntegrationSettings model instead. + """ + # Extract pk from kwargs if not passed as parameter (DRF passes via **kwargs) + if not pk: + pk = kwargs.get('pk') + return self.save_settings(request, pk) + + @action(detail=True, methods=['post'], url_path='test', url_name='test', + permission_classes=[IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper]) + def test_connection(self, request, pk=None): + """ + Test API connection for OpenAI or Runware + Supports two modes: + - with_response=false: Simple connection test (GET /v1/models) + - with_response=true: Full response test with ping message + """ + integration_type = pk # 'openai', 'runware' + + logger.info(f"[test_connection] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}") + + if not integration_type: + return error_response( + error='Integration type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get API key and config from request or saved settings + config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {} + api_key = request.data.get('apiKey') or config.get('apiKey') + + # Merge request.data with config if config is a dict + if not isinstance(config, dict): + config = {} + + if not api_key: + # Try to get from saved settings + account = getattr(request, 'account', None) + logger.info(f"[test_connection] Account from request: {account.id if account else None}") + # Fallback to user's account + if not account: + user = getattr(request, 'user', None) + if user and hasattr(user, 'is_authenticated') and user.is_authenticated: + account = getattr(user, 'account', None) + # Fallback to default account + if not account: + from igny8_core.auth.models import Account + try: + account = Account.objects.first() + except Exception: + pass + + if account: + try: + from .models import IntegrationSettings + logger.info(f"[test_connection] Looking for saved settings for account {account.id}") + saved_settings = IntegrationSettings.objects.get( + integration_type=integration_type, + account=account + ) + api_key = saved_settings.config.get('apiKey') + logger.info(f"[test_connection] Found saved settings, has_apiKey={bool(api_key)}") + except IntegrationSettings.DoesNotExist: + logger.warning(f"[test_connection] No saved settings found for {integration_type} and account {account.id}") + pass + + if not api_key: + logger.error(f"[test_connection] No API key found in request or saved settings") + return error_response( + error='API key is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})") + try: + if integration_type == 'openai': + return self._test_openai(api_key, config, request) + elif integration_type == 'runware': + return self._test_runware(api_key, request) + else: + return error_response( + error=f'Validation not supported for {integration_type}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + except Exception as e: + logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True) + import traceback + error_trace = traceback.format_exc() + logger.error(f"Full traceback: {error_trace}") + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def _test_openai(self, api_key: str, config: dict = None, request=None): + """ + Test OpenAI API connection. + EXACT match to reference plugin's igny8_test_connection() function. + Reference: ai/openai-api.php line 186-309 + """ + import requests + + # Get model from config or use default (reference plugin: get_option('igny8_model', 'gpt-4.1')) + model = (config or {}).get('model', 'gpt-4.1') if config else 'gpt-4.1' + + # Check if test with response is requested (reference plugin: $with_response parameter) + with_response = (config or {}).get('with_response', False) if config else False + + if with_response: + # Test with actual API call (reference plugin: test with chat completion) + request_body = { + 'model': model, + 'messages': [ + { + 'role': 'user', + 'content': 'test ping, reply with: OK! Ping Received. Also tell me: what is your maximum token limit that I can use in 1 request?' + } + ], + 'temperature': 0.7, + } + + try: + response = requests.post( + 'https://api.openai.com/v1/chat/completions', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + json=request_body, + timeout=15 + ) + + if response.status_code >= 200 and response.status_code < 300: + response_data = response.json() + + if 'choices' in response_data and len(response_data['choices']) > 0: + response_text = response_data['choices'][0]['message']['content'].strip() + + # Extract token usage information (reference plugin: line 269-271) + usage = response_data.get('usage', {}) + input_tokens = usage.get('prompt_tokens', 0) + output_tokens = usage.get('completion_tokens', 0) + total_tokens = usage.get('total_tokens', 0) + + # Calculate cost using model rates (reference plugin: line 274-275) + from igny8_core.utils.ai_processor import MODEL_RATES + rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00}) + cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000 + + return success_response( + data={ + 'message': 'API connection and response test successful!', + 'model_used': model, + 'response': response_text, + 'tokens_used': f"{input_tokens} / {output_tokens}", + 'total_tokens': total_tokens, + 'cost': f'${cost:.4f}', + 'full_response': response_data, + }, + request=request + ) + else: + return error_response( + error='API responded but no content received', + errors={'response': [response.text[:500]]}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + else: + body = response.text + # Map OpenAI API errors to appropriate HTTP status codes + # OpenAI 401 (invalid API key) should be 400 (Bad Request) in our API + # OpenAI 4xx errors are client errors (invalid request) -> 400 + # OpenAI 5xx errors are server errors -> 500 + if response.status_code == 401: + # Invalid API key - this is a validation error, not an auth error + status_code = status.HTTP_400_BAD_REQUEST + elif 400 <= response.status_code < 500: + # Other client errors from OpenAI (invalid request, rate limit, etc.) + status_code = status.HTTP_400_BAD_REQUEST + elif response.status_code >= 500: + # Server errors from OpenAI + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + else: + status_code = response.status_code + + return error_response( + error=f'HTTP {response.status_code} – {body[:200]}', + status_code=status_code, + request=request + ) + except requests.exceptions.RequestException as e: + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + else: + # Simple connection test without API call (reference plugin: GET /v1/models) + try: + response = requests.get( + 'https://api.openai.com/v1/models', + headers={ + 'Authorization': f'Bearer {api_key}', + }, + timeout=10 + ) + + if response.status_code >= 200 and response.status_code < 300: + return success_response( + data={ + 'message': 'API connection successful!', + 'model_used': model, + 'response': 'Connection verified without API call' + }, + request=request + ) + else: + body = response.text + # Map OpenAI API errors to appropriate HTTP status codes + # OpenAI 401 (invalid API key) should be 400 (Bad Request) in our API + # OpenAI 4xx errors are client errors (invalid request) -> 400 + # OpenAI 5xx errors are server errors -> 500 + if response.status_code == 401: + # Invalid API key - this is a validation error, not an auth error + status_code = status.HTTP_400_BAD_REQUEST + elif 400 <= response.status_code < 500: + # Other client errors from OpenAI (invalid request, rate limit, etc.) + status_code = status.HTTP_400_BAD_REQUEST + elif response.status_code >= 500: + # Server errors from OpenAI + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + else: + status_code = response.status_code + + return error_response( + error=f'HTTP {response.status_code} – {body[:200]}', + status_code=status_code, + request=request + ) + except requests.exceptions.RequestException as e: + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def _test_runware(self, api_key: str, request): + """ + Test Runware API connection using 64x64 image generation (ping validation) + Reference: Uses same format as image generation but with minimal 64x64 size for fast validation + """ + from igny8_core.utils.ai_processor import AIProcessor + + # Get account from request + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + if user and hasattr(user, 'is_authenticated') and user.is_authenticated: + account = getattr(user, 'account', None) + # Fallback to default account + if not account: + from igny8_core.auth.models import Account + try: + account = Account.objects.first() + except Exception: + pass + + try: + # EXACT match to reference plugin: core/admin/ajax.php line 4946-5003 + # Reference plugin uses: 128x128, steps=2, CFGScale=5, prompt='test image connection' + import requests + import uuid + import json + + test_prompt = 'test image connection' + + # Prepare payload EXACTLY as reference plugin + payload = [ + { + 'taskType': 'authentication', + 'apiKey': api_key + }, + { + 'taskType': 'imageInference', + 'taskUUID': str(uuid.uuid4()), + 'positivePrompt': test_prompt, + 'model': 'runware:97@1', + 'width': 128, # Reference plugin uses 128x128, not 64x64 + 'height': 128, + 'negativePrompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title', + 'steps': 2, # Low steps for fast testing + 'CFGScale': 5, + 'numberResults': 1 + } + ] + + logger.info("[_test_runware] Testing Runware API with 128x128 image generation (matching reference plugin)") + logger.info(f"[_test_runware] Payload: {json.dumps(payload, indent=2)}") + + # Make API request + response = requests.post( + 'https://api.runware.ai/v1', + headers={'Content-Type': 'application/json'}, + json=payload, + timeout=30 + ) + + logger.info(f"[_test_runware] Response status: {response.status_code}") + + if response.status_code != 200: + error_text = response.text + logger.error(f"[_test_runware] HTTP error {response.status_code}: {error_text[:200]}") + return error_response( + error=f'HTTP {response.status_code}: {error_text[:200]}', + status_code=response.status_code, + request=request + ) + + # Parse response - Reference plugin checks: $body['data'][0]['imageURL'] + body = response.json() + logger.info(f"[_test_runware] Response body type: {type(body)}") + logger.info(f"[_test_runware] Response body: {json.dumps(body, indent=2)[:1000]}") + + # Reference plugin line 4996: if (isset($body['data'][0]['imageURL'])) + if isinstance(body, dict) and 'data' in body: + data = body['data'] + if isinstance(data, list) and len(data) > 0: + first_item = data[0] + image_url = first_item.get('imageURL') or first_item.get('image_url') + if image_url: + logger.info(f"[_test_runware] Success! Image URL: {image_url[:50]}...") + return success_response( + data={ + 'message': '✅ Runware API connected successfully!', + 'image_url': image_url, + 'cost': '$0.0090', + 'provider': 'runware', + 'model': 'runware:97@1', + 'size': '128x128' + }, + request=request + ) + + # Check for errors - Reference plugin line 4998: elseif (isset($body['errors'][0]['message'])) + if isinstance(body, dict) and 'errors' in body: + errors = body['errors'] + if isinstance(errors, list) and len(errors) > 0: + error_msg = errors[0].get('message', 'Unknown Runware API error') + logger.error(f"[_test_runware] Runware API error: {error_msg}") + return error_response( + error=f'❌ {error_msg}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + # Unknown response format + logger.error(f"[_test_runware] Unknown response format: {body}") + return error_response( + error='❌ Unknown response from Runware.', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + except Exception as e: + logger.error(f"[_test_runware] Exception in Runware API test: {str(e)}", exc_info=True) + return error_response( + error=f'Runware API test failed: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def generate_image(self, request, pk=None, **kwargs): + """ + Generate image using the configured image generation service + POST /api/v1/system/settings/integrations/image_generation/generate/ + Note: This method is called via custom URL pattern, not @action decorator + """ + # Extract pk from kwargs if not passed as parameter (DRF passes via **kwargs) + if not pk: + pk = kwargs.get('pk') + + # Log detailed request info for debugging + logger.info("=" * 80) + logger.info("[generate_image] ENDPOINT CALLED - Image generation request received") + logger.info(f"[generate_image] pk parameter: {pk}") + logger.info(f"[generate_image] kwargs: {kwargs}") + logger.info(f"[generate_image] request.path: {request.path}") + logger.info(f"[generate_image] request.method: {request.method}") + logger.info(f"[generate_image] request.META.get('PATH_INFO'): {request.META.get('PATH_INFO')}") + logger.info(f"[generate_image] request.META.get('REQUEST_URI'): {request.META.get('REQUEST_URI', 'N/A')}") + logger.info(f"[generate_image] request.META.get('HTTP_HOST'): {request.META.get('HTTP_HOST', 'N/A')}") + logger.info(f"[generate_image] request.META.get('HTTP_REFERER'): {request.META.get('HTTP_REFERER', 'N/A')}") + logger.info(f"[generate_image] request.data: {request.data}") + + if pk != 'image_generation': + logger.error(f"[generate_image] Invalid pk: {pk}, expected 'image_generation'") + return error_response( + error=f'Image generation endpoint only available for image_generation integration, got: {pk}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get account + logger.info("[generate_image] Step 1: Getting account") + account = getattr(request, 'account', None) + if not account: + user = getattr(request, 'user', None) + logger.info(f"[generate_image] No account in request, checking user: {user}") + if user and hasattr(user, 'is_authenticated') and user.is_authenticated: + account = getattr(user, 'account', None) + logger.info(f"[generate_image] Got account from user: {account}") + if not account: + logger.info("[generate_image] No account found, trying to get first account from DB") + from igny8_core.auth.models import Account + try: + account = Account.objects.first() + logger.info(f"[generate_image] Got first account from DB: {account}") + except Exception as e: + logger.error(f"[generate_image] Error getting account from DB: {e}") + pass + + if not account: + logger.error("[generate_image] ERROR: No account found, returning error response") + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + logger.info(f"[generate_image] Account resolved: {account.id if account else 'None'}") + + # Get request parameters + logger.info("[generate_image] Step 2: Extracting request parameters") + prompt = request.data.get('prompt', '') + negative_prompt = request.data.get('negative_prompt', '') + image_type = request.data.get('image_type', 'realistic') + image_size = request.data.get('image_size', '1024x1024') + image_format = request.data.get('image_format', 'webp') + provider = request.data.get('provider', 'openai') + model = request.data.get('model', 'dall-e-3') + + logger.info(f"[generate_image] Request parameters: provider={provider}, model={model}, image_type={image_type}, image_size={image_size}, prompt_length={len(prompt)}") + logger.info(f"[generate_image] IMPORTANT: Using ONLY {provider.upper()} provider for this request. NOT using both providers.") + + if not prompt: + logger.error("[generate_image] ERROR: Prompt is empty") + return error_response( + error='Prompt is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get API key from saved settings for the specified provider only + logger.info(f"[generate_image] Step 3: Getting API key for provider: {provider}") + from .models import IntegrationSettings + + # Only fetch settings for the specified provider + api_key = None + integration_enabled = False + integration_type = provider # 'openai' or 'runware' + + try: + integration_settings = IntegrationSettings.objects.get( + integration_type=integration_type, + account=account + ) + api_key = integration_settings.config.get('apiKey') + integration_enabled = integration_settings.is_active + logger.info(f"[generate_image] {integration_type.upper()} settings found: enabled={integration_enabled}, has_key={bool(api_key)}") + except IntegrationSettings.DoesNotExist: + logger.warning(f"[generate_image] {integration_type.upper()} settings not found in database") + api_key = None + integration_enabled = False + except Exception as e: + logger.error(f"[generate_image] Error getting {integration_type.upper()} settings: {e}") + api_key = None + integration_enabled = False + + # Validate provider and API key + logger.info(f"[generate_image] Step 4: Validating {provider} provider and API key") + if provider not in ['openai', 'runware']: + logger.error(f"[generate_image] ERROR: Invalid provider: {provider}") + return error_response( + error=f'Invalid provider: {provider}. Must be "openai" or "runware"', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + if not api_key or not integration_enabled: + logger.error(f"[generate_image] ERROR: {provider.upper()} API key not configured or integration not enabled") + return error_response( + error=f'{provider.upper()} API key not configured or integration not enabled', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + logger.info(f"[generate_image] {provider.upper()} API key validated successfully") + + # Generate image using AIProcessor + logger.info("[generate_image] Step 5: Creating AIProcessor and generating image") + try: + from igny8_core.utils.ai_processor import AIProcessor + processor = AIProcessor(account=account) + logger.info("[generate_image] AIProcessor created successfully") + + # Parse size + width, height = map(int, image_size.split('x')) + size_str = f'{width}x{height}' + logger.info(f"[generate_image] Image size parsed: {size_str}") + + logger.info(f"[generate_image] Calling processor.generate_image with: provider={provider}, model={model}, size={size_str}") + result = processor.generate_image( + prompt=prompt, + provider=provider, + model=model, + size=size_str, + n=1, + api_key=api_key, + negative_prompt=negative_prompt if provider == 'runware' else None, # OpenAI doesn't support negative prompts + ) + + logger.info(f"[generate_image] AIProcessor.generate_image returned: has_url={bool(result.get('url'))}, has_error={bool(result.get('error'))}") + + if result.get('error'): + logger.error(f"[generate_image] ERROR from AIProcessor: {result.get('error')}") + return error_response( + error=result['error'], + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + logger.info("[generate_image] Image generation successful, returning response") + response_data = { + 'image_url': result.get('url'), + 'revised_prompt': result.get('revised_prompt'), + 'model': model, + 'provider': provider, + 'cost': f"${result.get('cost', 0):.4f}" if result.get('cost') else None, + } + logger.info(f"[generate_image] Returning success response: {response_data}") + return success_response( + data=response_data, + request=request + ) + except Exception as e: + logger.error(f"[generate_image] EXCEPTION in image generation: {str(e)}", exc_info=True) + return error_response( + error=f'Failed to generate image: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def create(self, request): + """Create integration settings""" + integration_type = request.data.get('integration_type') + if not integration_type: + return error_response( + error='integration_type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + return self.save_settings(request, integration_type) + + def save_settings(self, request, pk=None): + """Save integration settings""" + integration_type = pk # 'openai', 'runware', 'gsc' + + logger.info(f"[save_settings] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}") + + if not integration_type: + return error_response( + error='Integration type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Ensure config is a dict + config = dict(request.data) if hasattr(request.data, 'dict') else (request.data if isinstance(request.data, dict) else {}) + logger.info(f"[save_settings] Config keys: {list(config.keys()) if isinstance(config, dict) else 'Not a dict'}") + + try: + # Get account - try multiple methods + account = getattr(request, 'account', None) + logger.info(f"[save_settings] Account from request: {account.id if account else None}") + + # Fallback 1: Get from authenticated user's account + if not account: + user = getattr(request, 'user', None) + if user and hasattr(user, 'is_authenticated') and user.is_authenticated: + try: + account = getattr(user, 'account', None) + except Exception as e: + logger.warning(f"Error getting account from user: {e}") + account = None + + # Fallback 2: If still no account, get default account (for development) + if not account: + from igny8_core.auth.models import Account + try: + # Get the first account as fallback (development only) + account = Account.objects.first() + except Exception as e: + logger.warning(f"Error getting default account: {e}") + account = None + + if not account: + logger.error(f"[save_settings] No account found after all fallbacks") + return error_response( + error='Account not found. Please ensure you are logged in.', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + logger.info(f"[save_settings] Using account: {account.id} ({account.name}, slug={account.slug}, status={account.status})") + + # Store integration settings in a simple model or settings table + # For now, we'll use a simple approach - store in IntegrationSettings model + # or use Django settings/database + + # Import IntegrationSettings model + from .models import IntegrationSettings + + # For image_generation, ensure provider is set correctly + if integration_type == 'image_generation': + # Map service to provider if service is provided + if 'service' in config and 'provider' not in config: + config['provider'] = config['service'] + # Ensure provider is set + if 'provider' not in config: + config['provider'] = config.get('service', 'openai') + # Set model based on provider + if config.get('provider') == 'openai' and 'model' not in config: + config['model'] = config.get('imageModel', 'dall-e-3') + elif config.get('provider') == 'runware' and 'model' not in config: + config['model'] = config.get('runwareModel', 'runware:97@1') + # Ensure all image settings have defaults + config.setdefault('image_type', 'realistic') + config.setdefault('max_in_article_images', 2) + config.setdefault('image_format', 'webp') + config.setdefault('desktop_enabled', True) + config.setdefault('mobile_enabled', True) + + # Set default image sizes based on provider/model + provider = config.get('provider', 'openai') + model = config.get('model', 'dall-e-3') + + if not config.get('featured_image_size'): + if provider == 'runware': + config['featured_image_size'] = '1280x832' + else: # openai + config['featured_image_size'] = '1024x1024' + + if not config.get('desktop_image_size'): + config['desktop_image_size'] = '1024x1024' + + # Get or create integration settings + logger.info(f"[save_settings] Attempting get_or_create for {integration_type} with account {account.id}") + integration_settings, created = IntegrationSettings.objects.get_or_create( + integration_type=integration_type, + account=account, + defaults={'config': config, 'is_active': config.get('enabled', False)} + ) + logger.info(f"[save_settings] get_or_create result: created={created}, id={integration_settings.id}") + + if not created: + logger.info(f"[save_settings] Updating existing settings (id={integration_settings.id})") + integration_settings.config = config + integration_settings.is_active = config.get('enabled', False) + integration_settings.save() + logger.info(f"[save_settings] Settings updated successfully") + + logger.info(f"[save_settings] Successfully saved settings for {integration_type}") + return success_response( + data={'config': config}, + message=f'{integration_type.upper()} settings saved successfully', + request=request + ) + + except Exception as e: + logger.error(f"Error saving integration settings for {integration_type}: {str(e)}", exc_info=True) + import traceback + error_trace = traceback.format_exc() + logger.error(f"Full traceback: {error_trace}") + return error_response( + error=f'Failed to save settings: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def get_settings(self, request, pk=None): + """Get integration settings - defaults to AWS-admin settings if account doesn't have its own""" + integration_type = pk + + if not integration_type: + return error_response( + error='Integration type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + try: + # Get account - try multiple methods (same as save_settings) + account = getattr(request, 'account', None) + + # Fallback 1: Get from authenticated user's account + if not account: + user = getattr(request, 'user', None) + if user and hasattr(user, 'is_authenticated') and user.is_authenticated: + try: + account = getattr(user, 'account', None) + except Exception as e: + logger.warning(f"Error getting account from user: {e}") + account = None + + from .models import IntegrationSettings + + # Get account-specific settings + if account: + try: + integration_settings = IntegrationSettings.objects.get( + integration_type=integration_type, + account=account + ) + response_data = { + 'id': integration_settings.integration_type, + 'enabled': integration_settings.is_active, + **integration_settings.config + } + return success_response( + data=response_data, + request=request + ) + except IntegrationSettings.DoesNotExist: + pass + except Exception as e: + logger.error(f"Error getting account-specific settings: {e}", exc_info=True) + + # Return empty config if no settings found + return success_response( + data={}, + request=request + ) + except Exception as e: + logger.error(f"Unexpected error in get_settings for {integration_type}: {e}", exc_info=True) + return error_response( + error=f'Failed to get settings: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + @action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings') + def get_image_generation_settings(self, request): + """Get image generation settings for current account""" + account = getattr(request, 'account', None) + + if not account: + # Fallback to user's account + user = getattr(request, 'user', None) + if user and hasattr(user, 'is_authenticated') and user.is_authenticated: + account = getattr(user, 'account', None) + # Fallback to default account + if not account: + from igny8_core.auth.models import Account + try: + account = Account.objects.first() + except Exception: + pass + + if not account: + return error_response( + error='Account not found', + status_code=status.HTTP_401_UNAUTHORIZED, + request=request + ) + + try: + from .models import IntegrationSettings + integration = IntegrationSettings.objects.get( + account=account, + integration_type='image_generation', + is_active=True + ) + + config = integration.config or {} + + # Debug: Log what's actually in the config + logger.info(f"[get_image_generation_settings] Full config: {config}") + logger.info(f"[get_image_generation_settings] Config keys: {list(config.keys())}") + logger.info(f"[get_image_generation_settings] model field: {config.get('model')}") + logger.info(f"[get_image_generation_settings] imageModel field: {config.get('imageModel')}") + + # Get model - try 'model' first, then 'imageModel' as fallback + model = config.get('model') or config.get('imageModel') or 'dall-e-3' + + # Set defaults for image sizes if not present + provider = config.get('provider', 'openai') + default_featured_size = '1280x832' if provider == 'runware' else '1024x1024' + + return success_response( + data={ + 'config': { + 'provider': config.get('provider', 'openai'), + 'model': model, + 'image_type': config.get('image_type', 'realistic'), + 'max_in_article_images': config.get('max_in_article_images', 2), + 'image_format': config.get('image_format', 'webp'), + 'desktop_enabled': config.get('desktop_enabled', True), + 'mobile_enabled': config.get('mobile_enabled', True), + 'featured_image_size': config.get('featured_image_size', default_featured_size), + 'desktop_image_size': config.get('desktop_image_size', '1024x1024'), + } + }, + request=request + ) + except IntegrationSettings.DoesNotExist: + return success_response( + data={ + 'config': { + 'provider': 'openai', + 'model': 'dall-e-3', + 'image_type': 'realistic', + 'max_in_article_images': 2, + 'image_format': 'webp', + 'desktop_enabled': True, + 'mobile_enabled': True, + } + }, + request=request + ) + except Exception as e: + logger.error(f"[get_image_generation_settings] Error: {str(e)}", exc_info=True) + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + @action(detail=False, methods=['get'], url_path='task_progress/(?P[^/.]+)', url_name='task-progress', + permission_classes=[IsAuthenticatedAndActive]) # Allow any authenticated user to check task progress + def task_progress(self, request, task_id=None): + """ + Get Celery task progress status + GET /api/v1/system/settings/task_progress// + + Permission: Any authenticated user can check task progress (not restricted to system accounts) + """ + if not task_id: + return error_response( + error='Task ID is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + import logging + logger = logging.getLogger(__name__) + + try: + # Try to import Celery AsyncResult + try: + from celery.result import AsyncResult + from kombu.exceptions import OperationalError as KombuOperationalError + # Try to import redis ConnectionError, but it might not be available + try: + from redis.exceptions import ConnectionError as RedisConnectionError + except ImportError: + # Redis might not be installed or ConnectionError might not exist + RedisConnectionError = ConnectionError + except ImportError: + logger.warning("Celery not available - task progress cannot be retrieved") + return success_response( + data={ + 'state': 'PENDING', + 'meta': { + 'percentage': 0, + 'message': 'Celery not available - cannot retrieve task status', + 'error': 'Celery not configured' + } + }, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + request=request + ) + + try: + # Create AsyncResult - this should not raise an exception even if task doesn't exist + task = AsyncResult(task_id) + + # Safely get task state - accessing task.state can raise ValueError if exception info is malformed + # or ConnectionError if backend is unavailable + try: + task_state = task.state + except (ValueError, KeyError) as state_exc: + # Task has malformed exception info - try to get error from multiple sources + logger.warning(f"Error accessing task.state (malformed exception info): {str(state_exc)}") + error_msg = 'Task failed - exception details unavailable' + error_type = 'UnknownError' + request_steps = [] + response_steps = [] + + # First, try to get from backend's stored meta (most reliable for our update_state calls) + try: + backend = task.backend + if hasattr(backend, 'get_task_meta'): + stored_meta = backend.get_task_meta(task_id) + if stored_meta and isinstance(stored_meta, dict): + meta = stored_meta.get('meta', {}) + if isinstance(meta, dict): + if 'error' in meta: + error_msg = meta.get('error') + if 'error_type' in meta: + error_type = meta.get('error_type', error_type) + if 'request_steps' in meta: + request_steps = meta.get('request_steps', []) + if 'response_steps' in meta: + response_steps = meta.get('response_steps', []) + except Exception as e: + logger.debug(f"Error getting from backend meta: {str(e)}") + + # Try to get error from task.result + if error_msg == 'Task failed - exception details unavailable': + try: + if hasattr(task, 'result'): + result = task.result + if isinstance(result, dict): + error_msg = result.get('error', error_msg) + error_type = result.get('error_type', error_type) + request_steps = result.get('request_steps', request_steps) + response_steps = result.get('response_steps', response_steps) + elif isinstance(result, str): + error_msg = result + except Exception as e: + logger.debug(f"Error extracting error from task.result: {str(e)}") + + # Also try to get error from task.info + if error_msg == 'Task failed - exception details unavailable': + try: + if hasattr(task, 'info') and task.info: + if isinstance(task.info, dict): + if 'error' in task.info: + error_msg = task.info['error'] + if 'error_type' in task.info: + error_type = task.info['error_type'] + if 'request_steps' in task.info: + request_steps = task.info.get('request_steps', request_steps) + if 'response_steps' in task.info: + response_steps = task.info.get('response_steps', response_steps) + except Exception as e: + logger.debug(f"Error extracting error from task.info: {str(e)}") + + return success_response( + data={ + 'state': 'FAILURE', + 'meta': { + 'error': error_msg, + 'error_type': error_type, + 'percentage': 0, + 'message': f'Error: {error_msg}', + 'request_steps': request_steps, + 'response_steps': response_steps, + } + }, + request=request + ) + except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc: + # Backend connection error - task might not be registered yet or backend is down + logger.warning(f"Backend connection error accessing task.state for {task_id}: {type(conn_exc).__name__}: {str(conn_exc)}") + return success_response( + data={ + 'state': 'PENDING', + 'meta': { + 'percentage': 0, + 'message': 'Task is being queued...', + 'phase': 'initializing', + 'error': None # Don't show as error, just pending + } + }, + request=request + ) + except Exception as state_exc: + logger.error(f"Unexpected error accessing task.state: {type(state_exc).__name__}: {str(state_exc)}") + return success_response( + data={ + 'state': 'UNKNOWN', + 'meta': { + 'error': f'Error accessing task: {str(state_exc)}', + 'percentage': 0, + 'message': f'Error: {str(state_exc)}', + } + }, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + # Check if task exists and is accessible + if task_state is None: + # Task doesn't exist or hasn't been registered yet + return success_response( + data={ + 'state': 'PENDING', + 'meta': { + 'percentage': 0, + 'message': 'Task not found or not yet registered', + 'phase': 'initializing', + } + }, + request=request + ) + + # Safely get task info/result + # Try to get error from multiple sources + task_result = None + task_info = None + error_message = None + error_type = None + + # First, try to get from backend's stored meta (most reliable for our update_state calls) + try: + backend = task.backend + if hasattr(backend, 'get_task_meta'): + stored_meta = backend.get_task_meta(task_id) + if stored_meta and isinstance(stored_meta, dict): + meta = stored_meta.get('meta', {}) + if isinstance(meta, dict): + if 'error' in meta: + error_message = meta.get('error') + error_type = meta.get('error_type', 'UnknownError') + except Exception as backend_err: + logger.debug(f"Could not get from backend meta: {backend_err}") + + try: + # Try to get result first - this often has the actual error + if not error_message and hasattr(task, 'result'): + try: + task_result = task.result + # If result is a dict with error, extract it + if isinstance(task_result, dict): + if 'error' in task_result: + error_message = task_result.get('error') + error_type = task_result.get('error_type', 'UnknownError') + elif 'success' in task_result and not task_result.get('success'): + error_message = task_result.get('error', 'Task failed') + error_type = task_result.get('error_type', 'UnknownError') + except Exception: + pass # Will try task.info next + except Exception: + pass + + # Then try task.info + if not error_message and hasattr(task, 'info'): + try: + task_info = task.info + if isinstance(task_info, dict): + if 'error' in task_info: + error_message = task_info.get('error') + error_type = task_info.get('error_type', 'UnknownError') + except (ValueError, KeyError, AttributeError) as info_exc: + # Log the actual exception that occurred + logger.error(f"Error accessing task.info for {task_id}: {type(info_exc).__name__}: {str(info_exc)}", exc_info=True) + # Try to get error from traceback if available + try: + if hasattr(task, 'traceback'): + error_message = f"Task failed: {str(task.traceback)}" + except: + pass + except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc: + # Backend connection error - task might not be registered yet + logger.warning(f"Backend connection error accessing task.info for {task_id}: {str(conn_exc)}") + task_info = None + except Exception as e: + logger.error(f"Unexpected error accessing task.info: {type(e).__name__}: {str(e)}", exc_info=True) + else: + if not hasattr(task, 'info'): + task_info = None + + # If still no error message, try to get from task.result again + if not error_message and hasattr(task, 'result'): + try: + task_result = task.result + if isinstance(task_result, dict): + if 'error' in task_result: + error_message = task_result.get('error') + error_type = task_result.get('error_type', 'UnknownError') + elif 'success' in task_result and not task_result.get('success'): + error_message = task_result.get('error', 'Task failed') + error_type = task_result.get('error_type', 'UnknownError') + elif isinstance(task_result, str): + error_message = task_result + elif isinstance(task_result, Exception): + error_message = str(task_result) + error_type = type(task_result).__name__ + except (ValueError, KeyError) as result_exc: + logger.warning(f"Error accessing task.result: {str(result_exc)}") + task_result = None + except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc: + # Backend connection error + logger.warning(f"Backend connection error accessing task.result for {task_id}: {str(conn_exc)}") + task_result = None + except Exception as info_error: + logger.warning(f"Unexpected error accessing task result: {str(info_error)}") + task_result = None + + # Use extracted error or fallback - try traceback as last resort + if not error_message: + try: + if hasattr(task, 'traceback') and task.traceback: + error_message = f"Task failed: {str(task.traceback)}" + except Exception: + pass + + if not error_message: + error_message = f"Task failed - check Celery worker logs for task {task_id}" + + if task_state == 'PROGRESS': + meta = task_info or {} + response_meta = { + 'current': meta.get('current', 0) if isinstance(meta, dict) else 0, + 'total': meta.get('total', 0) if isinstance(meta, dict) else 0, + 'percentage': meta.get('percentage', 0) if isinstance(meta, dict) else 0, + 'message': meta.get('message', 'Processing...') if isinstance(meta, dict) else 'Processing...', + 'phase': meta.get('phase', 'processing') if isinstance(meta, dict) else 'processing', + 'current_item': meta.get('current_item') if isinstance(meta, dict) else None, + 'completed': meta.get('completed', 0) if isinstance(meta, dict) else 0, + # Image generation progress fields + 'current_image': meta.get('current_image') if isinstance(meta, dict) else None, + 'current_image_id': meta.get('current_image_id') if isinstance(meta, dict) else None, + 'current_image_progress': meta.get('current_image_progress') if isinstance(meta, dict) else None, + 'total_images': meta.get('total_images') if isinstance(meta, dict) else None, + 'failed': meta.get('failed', 0) if isinstance(meta, dict) else 0, + 'results': meta.get('results', []) if isinstance(meta, dict) else [], + } + # Include step logs if available + if isinstance(meta, dict): + if 'request_steps' in meta: + response_meta['request_steps'] = meta['request_steps'] + if 'response_steps' in meta: + response_meta['response_steps'] = meta['response_steps'] + # Include image_queue if available (for image generation) + if 'image_queue' in meta: + response_meta['image_queue'] = meta['image_queue'] + return success_response( + data={ + 'state': task_state, + 'meta': response_meta + }, + request=request + ) + elif task_state == 'SUCCESS': + result = task_result or {} + meta = result if isinstance(result, dict) else {} + response_meta = { + 'percentage': 100, + 'message': meta.get('message', 'Task completed successfully') if isinstance(meta, dict) else 'Task completed successfully', + 'result': result, + 'details': meta if isinstance(meta, dict) else {}, + } + # Include step logs if available + if isinstance(meta, dict): + if 'request_steps' in meta: + response_meta['request_steps'] = meta['request_steps'] + if 'response_steps' in meta: + response_meta['response_steps'] = meta['response_steps'] + return success_response( + data={ + 'state': task_state, + 'meta': response_meta + }, + request=request + ) + elif task_state == 'FAILURE': + # Try to get error from task.info meta first (this is where run_ai_task sets it) + if not error_message and isinstance(task_info, dict): + error_message = task_info.get('error') or task_info.get('message', '') + error_type = task_info.get('error_type', 'UnknownError') + # Also check if message contains error info + if not error_message and 'message' in task_info: + msg = task_info.get('message', '') + if msg and 'Error:' in msg: + error_message = msg.replace('Error: ', '') + + # Use extracted error_message if available, otherwise try to get from error_info + if not error_message: + error_info = task_info + if isinstance(error_info, Exception): + error_message = str(error_info) + elif isinstance(error_info, dict): + error_message = error_info.get('error') or error_info.get('message', '') or str(error_info) + elif error_info: + error_message = str(error_info) + + # Final fallback - ensure we always have an error message + if not error_message or error_message.strip() == '': + error_message = f'Task execution failed - check Celery worker logs for task {task_id}' + error_type = 'ExecutionError' + + # If still no error message, try to get from task backend directly + if not error_message: + try: + # Try to get from backend's stored result + backend = task.backend + if hasattr(backend, 'get'): + stored = backend.get(task_id) + if stored and isinstance(stored, dict): + if 'error' in stored: + error_message = stored['error'] + elif isinstance(stored.get('result'), dict) and 'error' in stored['result']: + error_message = stored['result']['error'] + except Exception as backend_err: + logger.warning(f"Error getting from backend: {backend_err}") + + # Final fallback + if not error_message: + error_message = 'Task failed - check backend logs for details' + + response_meta = { + 'error': error_message, + 'percentage': 0, + 'message': f'Error: {error_message}', + } + + # Include error_type if available + if error_type: + response_meta['error_type'] = error_type + + # Include step logs if available (from task result or error_info) + result = task_result or {} + meta = result if isinstance(result, dict) else (task_info if isinstance(task_info, dict) else {}) + if isinstance(meta, dict): + if 'request_steps' in meta: + response_meta['request_steps'] = meta['request_steps'] + if 'response_steps' in meta: + response_meta['response_steps'] = meta['response_steps'] + # Also include error_type if available in meta + if 'error_type' in meta and not error_type: + response_meta['error_type'] = meta['error_type'] + # Also check for error in meta directly + if 'error' in meta and not error_message: + error_message = meta['error'] + response_meta['error'] = error_message + if 'error_type' in meta and not error_type: + error_type = meta['error_type'] + response_meta['error_type'] = error_type + + return success_response( + data={ + 'state': task_state, + 'meta': response_meta + }, + request=request + ) + else: + # PENDING, STARTED, or other states + return success_response( + data={ + 'state': task_state, + 'meta': { + 'percentage': 0, + 'message': 'Task is starting...', + 'phase': 'initializing', + } + }, + request=request + ) + except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_error: + # Backend connection error - task might not be registered yet or backend is down + logger.warning(f"Backend connection error for task {task_id}: {type(conn_error).__name__}: {str(conn_error)}") + return success_response( + data={ + 'state': 'PENDING', + 'meta': { + 'percentage': 0, + 'message': 'Task is being queued...', + 'phase': 'initializing', + 'error': None # Don't show as error, just pending + } + }, + request=request + ) + except Exception as task_error: + logger.error(f"Error accessing Celery task {task_id}: {type(task_error).__name__}: {str(task_error)}", exc_info=True) + return success_response( + data={ + 'state': 'UNKNOWN', + 'meta': { + 'percentage': 0, + 'message': f'Error accessing task: {str(task_error)}', + 'error': str(task_error) + } + }, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + except Exception as e: + # Check if it's a connection-related error - treat as PENDING instead of error + error_type = type(e).__name__ + error_str = str(e).lower() + is_connection_error = ( + 'connection' in error_str or + 'connect' in error_str or + 'timeout' in error_str or + 'unavailable' in error_str or + 'network' in error_str or + error_type in ('ConnectionError', 'TimeoutError', 'OperationalError') + ) + + if is_connection_error: + logger.warning(f"Connection error getting task progress for {task_id}: {error_type}: {str(e)}") + return success_response( + data={ + 'state': 'PENDING', + 'meta': { + 'percentage': 0, + 'message': 'Task is being queued...', + 'phase': 'initializing', + 'error': None + } + }, + request=request + ) + else: + logger.error(f"Error getting task progress for {task_id}: {error_type}: {str(e)}", exc_info=True) + return success_response( + data={ + 'state': 'ERROR', + 'meta': { + 'error': f'Error getting task status: {str(e)}', + 'percentage': 0, + 'message': f'Error: {str(e)}' + } + }, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) +