Files
igny8/backend/igny8_core/ai/tasks.py
IGNY8 VPS (Salman) 71fe687681 image max count
2025-12-17 13:06:42 +00:00

761 lines
38 KiB
Python

"""
Unified Celery task entrypoint for all AI functions
"""
import logging
from celery import shared_task
from igny8_core.ai.engine import AIEngine
from igny8_core.ai.registry import get_function_instance
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def run_ai_task(self, function_name: str, payload: dict, account_id: int):
"""
Single Celery entrypoint for all AI functions.
Dynamically loads and executes the requested function.
Args:
function_name: Name of the AI function (e.g., 'auto_cluster')
payload: Function-specific payload
account_id: Account ID for account isolation (required)
Raises:
Returns error dict if account_id not provided or account not found
"""
logger.info("=" * 80)
logger.info(f"run_ai_task STARTED: {function_name}")
logger.info(f" - Task ID: {self.request.id}")
logger.info(f" - Function: {function_name}")
logger.info(f" - Account ID: {account_id}")
logger.info(f" - Payload keys: {list(payload.keys())}")
logger.info("=" * 80)
try:
# Validate account_id is provided
if not account_id:
error_msg = "account_id is required for AI task execution"
logger.error(f"[run_ai_task] {error_msg}")
return {
'success': False,
'error': error_msg,
'error_type': 'ConfigurationError'
}
# Get account and validate it exists
from igny8_core.auth.models import Account
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
error_msg = f"Account {account_id} not found"
logger.error(f"[run_ai_task] {error_msg}")
return {
'success': False,
'error': error_msg,
'error_type': 'AccountNotFound'
}
# Get function from registry
fn = get_function_instance(function_name)
if not fn:
error_msg = f'Function {function_name} not found in registry'
logger.error(error_msg)
return {
'success': False,
'error': error_msg
}
# Create engine and execute
engine = AIEngine(celery_task=self, account=account)
result = engine.execute(fn, payload)
logger.info("=" * 80)
logger.info(f"run_ai_task COMPLETED: {function_name}")
logger.info(f" - Success: {result.get('success')}")
if not result.get('success'):
logger.error(f" - Error: {result.get('error')}")
logger.info("=" * 80)
# If execution failed, update state and return error (don't raise to avoid serialization issues)
if not result.get('success'):
error_msg = result.get('error', 'Task execution failed')
error_type = result.get('error_type', 'ExecutionError')
# Update task state with error details
error_meta = {
'error': error_msg,
'error_type': error_type,
'function_name': function_name,
'phase': result.get('phase', 'ERROR'),
'percentage': 0,
'message': f'Error: {error_msg}',
'request_steps': result.get('request_steps', []),
'response_steps': result.get('response_steps', [])
}
try:
self.update_state(
state='FAILURE',
meta=error_meta
)
except Exception as update_err:
logger.warning(f"Failed to update task state: {update_err}")
# Return error result - Celery will mark as FAILURE based on state
# Don't raise exception to avoid serialization issues
return {
'success': False,
'error': error_msg,
'error_type': error_type,
**error_meta
}
return result
except Exception as e:
error_type = type(e).__name__
error_msg = str(e)
logger.error("=" * 80)
logger.error(f"run_ai_task FAILED: {function_name}")
logger.error(f" - Error: {error_type}: {error_msg}")
logger.error("=" * 80, exc_info=True)
# Update task state with error details (don't raise to avoid serialization issues)
error_meta = {
'error': error_msg,
'error_type': error_type,
'function_name': function_name,
'phase': 'ERROR',
'percentage': 0,
'message': f'Error: {error_msg}'
}
try:
self.update_state(
state='FAILURE',
meta=error_meta
)
except Exception as update_err:
logger.warning(f"Failed to update task state: {update_err}")
# Return error result - don't raise to avoid Celery serialization issues
return {
'success': False,
'error': error_msg,
'error_type': error_type,
'function_name': function_name,
**error_meta
}
@shared_task(bind=True, name='igny8_core.ai.tasks.process_image_generation_queue')
def process_image_generation_queue(self, image_ids: list, account_id: int = None, content_id: int = None):
"""
Process image generation queue sequentially (one image at a time)
Updates Celery task meta with progress for each image
"""
from typing import List
from igny8_core.modules.writer.models import Images, Content
from igny8_core.modules.system.models import IntegrationSettings
from igny8_core.ai.ai_core import AICore
from igny8_core.ai.prompts import PromptRegistry
logger.info("=" * 80)
logger.info(f"process_image_generation_queue STARTED")
logger.info(f" - Task ID: {self.request.id}")
logger.info(f" - Image IDs: {image_ids}")
logger.info(f" - Account ID: {account_id}")
logger.info(f" - Content ID: {content_id}")
logger.info("=" * 80)
account = None
if account_id:
from igny8_core.auth.models import Account
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
logger.error(f"Account {account_id} not found")
return {'success': False, 'error': 'Account not found'}
# Initialize progress tracking
total_images = len(image_ids)
completed = 0
failed = 0
results = []
# Get image generation settings from IntegrationSettings
# Always use system account settings (aws-admin) for global configuration
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings from aws-admin")
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 aws-admin account")
return {'success': False, 'error': 'Image generation settings not found in aws-admin account'}
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(
account=account,
integration_type=provider, # Use the provider from settings
is_active=True
)
logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found for account {account.id}")
except IntegrationSettings.DoesNotExist:
# 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)
except Exception as e:
logger.warning(f"Failed to get image prompt template: {e}, using fallback")
image_prompt_template = '{image_type} image for blog post titled "{post_title}": {image_prompt}'
# Get negative prompt for Runware (only needed for Runware provider)
negative_prompt = None
if provider == 'runware':
try:
negative_prompt = PromptRegistry.get_negative_prompt(account)
except Exception as e:
logger.warning(f"Failed to get negative prompt: {e}")
negative_prompt = None
# Initialize AICore
ai_core = AICore(account=account)
# Process each image sequentially
for index, image_id in enumerate(image_ids, 1):
try:
# Update task meta: current image processing (starting at 0%)
self.update_state(
state='PROGRESS',
meta={
'current_image': index,
'total_images': total_images,
'completed': completed,
'failed': failed,
'status': 'processing',
'current_image_id': image_id,
'current_image_progress': 0,
'results': results
}
)
# Load image record
logger.info(f"[process_image_generation_queue] Image {index}/{total_images} (ID: {image_id}): Loading from database")
try:
image = Images.objects.get(id=image_id, account=account)
logger.info(f"[process_image_generation_queue] Image {image_id} loaded:")
logger.info(f" - Type: {image.image_type}")
logger.info(f" - Status: {image.status}")
logger.info(f" - Prompt length: {len(image.prompt) if image.prompt else 0} chars")
logger.info(f" - Prompt preview: {image.prompt[:100] if image.prompt else 'None'}...")
logger.info(f" - Content ID: {image.content.id if image.content else 'None'}")
except Images.DoesNotExist:
logger.error(f"[process_image_generation_queue] Image {image_id} not found in database")
logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}")
results.append({
'image_id': image_id,
'status': 'failed',
'error': 'Image record not found'
})
failed += 1
continue
except Exception as e:
logger.error(f"[process_image_generation_queue] ERROR loading image {image_id}: {e}", exc_info=True)
results.append({
'image_id': image_id,
'status': 'failed',
'error': f'Error loading image: {str(e)[:180]}'
})
failed += 1
continue
# Check if prompt exists
if not image.prompt:
logger.warning(f"Image {image_id} has no prompt")
results.append({
'image_id': image_id,
'status': 'failed',
'error': 'No prompt found'
})
failed += 1
continue
# Get content for template formatting
content = image.content
if not content:
logger.warning(f"Image {image_id} has no content")
results.append({
'image_id': image_id,
'status': 'failed',
'error': 'No content associated'
})
failed += 1
continue
# Format template with image prompt from database
# For DALL-E 2: Use image prompt directly (no template), 1000 char limit
# For DALL-E 3: Use template with placeholders, 4000 char limit
# CRITICAL: DALL-E 2 has 1000 char limit, DALL-E 3 has 4000 char limit
image_prompt = image.prompt or ""
# Determine character limit based on model
if model == 'dall-e-2':
max_prompt_length = 1000
elif model == 'dall-e-3':
max_prompt_length = 4000
else:
# Default to 1000 for safety
max_prompt_length = 1000
logger.warning(f"Unknown model '{model}', using 1000 char limit")
logger.info(f"[process_image_generation_queue] Model: {model}, Max prompt length: {max_prompt_length} chars")
if model == 'dall-e-2':
# DALL-E 2: Use image prompt directly, no template
logger.info(f"[process_image_generation_queue] Using DALL-E 2 - skipping template, using image prompt directly")
formatted_prompt = image_prompt
# Truncate to 1000 chars if needed
if len(formatted_prompt) > max_prompt_length:
logger.warning(f"DALL-E 2 prompt too long ({len(formatted_prompt)} chars), truncating to {max_prompt_length}")
truncated = formatted_prompt[:max_prompt_length - 3]
last_space = truncated.rfind(' ')
if last_space > max_prompt_length * 0.9:
formatted_prompt = truncated[:last_space] + "..."
else:
formatted_prompt = formatted_prompt[:max_prompt_length]
else:
# DALL-E 3 and others: Use template
try:
# Truncate post_title (max 200 chars for DALL-E 3 to leave room for image_prompt)
post_title = content.title or content.meta_title or f"Content #{content.id}"
if len(post_title) > 200:
post_title = post_title[:197] + "..."
# Calculate actual template length with placeholders filled
# Format template with dummy values to measure actual length
template_with_dummies = image_prompt_template.format(
image_type=image_type,
post_title='X' * len(post_title), # Use same length as actual post_title
image_prompt='' # Empty to measure template overhead
)
template_overhead = len(template_with_dummies)
# Calculate max image_prompt length: max_prompt_length - template_overhead - safety margin (50)
max_image_prompt_length = max_prompt_length - template_overhead - 50
if max_image_prompt_length < 100:
# If template is too long, use minimum 100 chars for image_prompt
max_image_prompt_length = 100
logger.warning(f"Template is very long ({template_overhead} chars), limiting image_prompt to {max_image_prompt_length}")
logger.info(f"[process_image_generation_queue] Template overhead: {template_overhead} chars, max image_prompt: {max_image_prompt_length} chars")
# Truncate image_prompt to calculated max
if len(image_prompt) > max_image_prompt_length:
logger.warning(f"Image prompt too long ({len(image_prompt)} chars), truncating to {max_image_prompt_length}")
# Word-aware truncation
truncated = image_prompt[:max_image_prompt_length - 3]
last_space = truncated.rfind(' ')
if last_space > max_image_prompt_length * 0.8: # Only if we have a reasonable space
image_prompt = truncated[:last_space] + "..."
else:
image_prompt = image_prompt[:max_image_prompt_length - 3] + "..."
formatted_prompt = image_prompt_template.format(
image_type=image_type,
post_title=post_title,
image_prompt=image_prompt
)
# CRITICAL: Final safety check - truncate to model-specific limit
if len(formatted_prompt) > max_prompt_length:
logger.warning(f"Formatted prompt too long ({len(formatted_prompt)} chars), truncating to {max_prompt_length} for {model}")
# Try word-aware truncation
truncated = formatted_prompt[:max_prompt_length - 3]
last_space = truncated.rfind(' ')
if last_space > max_prompt_length * 0.9: # Only use word-aware if we have a reasonable space
formatted_prompt = truncated[:last_space] + "..."
else:
formatted_prompt = formatted_prompt[:max_prompt_length] # Hard truncate
# Double-check after truncation - MUST be <= max_prompt_length
if len(formatted_prompt) > max_prompt_length:
logger.error(f"Prompt still too long after truncation ({len(formatted_prompt)} chars), forcing hard truncate to {max_prompt_length}")
formatted_prompt = formatted_prompt[:max_prompt_length]
except Exception as e:
# Fallback if template formatting fails
logger.warning(f"Prompt template formatting failed: {e}, using image prompt directly")
formatted_prompt = image_prompt
# CRITICAL: Truncate to model-specific limit even in fallback
if len(formatted_prompt) > max_prompt_length:
logger.warning(f"Fallback prompt too long ({len(formatted_prompt)} chars), truncating to {max_prompt_length} for {model}")
# Try word-aware truncation
truncated = formatted_prompt[:max_prompt_length - 3]
last_space = truncated.rfind(' ')
if last_space > max_prompt_length * 0.9:
formatted_prompt = truncated[:last_space] + "..."
else:
formatted_prompt = formatted_prompt[:max_prompt_length] # Hard truncate
# Final hard truncate if still too long - MUST be <= max_prompt_length
if len(formatted_prompt) > max_prompt_length:
logger.error(f"Fallback prompt still too long ({len(formatted_prompt)} chars), forcing hard truncate to {max_prompt_length}")
formatted_prompt = formatted_prompt[:max_prompt_length]
# Generate image (using same approach as test image generation)
logger.info(f"[process_image_generation_queue] Generating image {index}/{total_images} (ID: {image_id})")
logger.info(f"[process_image_generation_queue] Provider: {provider}, Model: {model}")
logger.info(f"[process_image_generation_queue] Prompt length: {len(formatted_prompt)} (MUST be <= {max_prompt_length} for {model})")
if len(formatted_prompt) > max_prompt_length:
logger.error(f"[process_image_generation_queue] ERROR: Prompt is {len(formatted_prompt)} chars, truncating NOW to {max_prompt_length}!")
formatted_prompt = formatted_prompt[:max_prompt_length]
logger.info(f"[process_image_generation_queue] Final prompt length: {len(formatted_prompt)}")
logger.info(f"[process_image_generation_queue] Image type: {image_type}")
# Update progress: Starting image generation (0%)
self.update_state(
state='PROGRESS',
meta={
'current_image': index,
'total_images': total_images,
'completed': completed,
'failed': failed,
'status': 'processing',
'current_image_id': image_id,
'current_image_progress': 0,
'results': results
}
)
# Use appropriate size based on image type
if image.image_type == 'featured':
image_size = featured_image_size # Read from config
elif image.image_type == 'desktop':
image_size = desktop_image_size
elif image.image_type == 'mobile':
image_size = '512x512' # Fixed mobile size
else: # in_article or other
image_size = in_article_image_size # Read from config, default 512x512
result = ai_core.generate_image(
prompt=formatted_prompt,
provider=provider,
model=model,
size=image_size,
api_key=api_key,
negative_prompt=negative_prompt,
function_name='generate_images_from_prompts'
)
# Update progress: Image generation complete (50%)
self.update_state(
state='PROGRESS',
meta={
'current_image': index,
'total_images': total_images,
'completed': completed,
'failed': failed,
'status': 'processing',
'current_image_id': image_id,
'current_image_progress': 50,
'results': results
}
)
logger.info(f"[process_image_generation_queue] Image generation result: has_url={bool(result.get('url'))}, has_error={bool(result.get('error'))}")
# Check for errors
if result.get('error'):
error_message = result.get('error', 'Unknown error')
logger.error(f"Image generation failed for {image_id}: {error_message}")
# Truncate error message to avoid database field length issues
# Some database fields may have 200 char limit, so truncate to 180 to be safe
truncated_error = error_message[:180] if len(error_message) > 180 else error_message
# Update image record: failed
try:
image.status = 'failed'
image.save(update_fields=['status'])
except Exception as save_error:
logger.error(f"Failed to save image status to database: {save_error}", exc_info=True)
# Continue even if save fails
results.append({
'image_id': image_id,
'status': 'failed',
'error': truncated_error
})
failed += 1
else:
logger.info(f"Image generation successful for {image_id}")
# Update image record: success
image_url = result.get('url')
logger.info(f"[process_image_generation_queue] Image {image_id} - URL received: {image_url[:100] if image_url else 'None'}...")
logger.info(f"[process_image_generation_queue] Image {image_id} - URL length: {len(image_url) if image_url else 0} characters")
# Update progress: Downloading image (75%)
self.update_state(
state='PROGRESS',
meta={
'current_image': index,
'total_images': total_images,
'completed': completed,
'failed': failed,
'status': 'processing',
'current_image_id': image_id,
'current_image_progress': 75,
'results': results
}
)
# Download and save image to /data/app/igny8/frontend/public/images/ai-images
saved_file_path = None
if image_url:
try:
import os
import requests
import time
# Use the correct path: /data/app/igny8/frontend/public/images/ai-images
# This is web-accessible via /images/ai-images/ (Vite serves from public/)
images_dir = '/data/app/igny8/frontend/public/images/ai-images'
# Create directory if it doesn't exist
os.makedirs(images_dir, exist_ok=True)
logger.info(f"[process_image_generation_queue] Image {image_id} - Using directory: {images_dir}")
# Generate filename: image_{image_id}_{timestamp}.png (or .webp for Runware)
timestamp = int(time.time())
# Use webp extension if provider is Runware, otherwise png
file_ext = 'webp' if provider == 'runware' else 'png'
filename = f"image_{image_id}_{timestamp}.{file_ext}"
file_path = os.path.join(images_dir, filename)
# Download image
logger.info(f"[process_image_generation_queue] Image {image_id} - Downloading from: {image_url[:100]}...")
response = requests.get(image_url, timeout=60)
response.raise_for_status()
# Save to file
with open(file_path, 'wb') as f:
f.write(response.content)
# Verify file was actually saved and exists
if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
saved_file_path = file_path
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved to: {file_path} ({len(response.content)} bytes, verified: {os.path.getsize(file_path)} bytes on disk)")
else:
logger.error(f"[process_image_generation_queue] Image {image_id} - File write appeared to succeed but file not found or empty: {file_path}")
saved_file_path = None
raise Exception(f"File was not saved successfully to {file_path}")
except Exception as download_error:
logger.error(f"[process_image_generation_queue] Image {image_id} - Failed to download/save image: {download_error}", exc_info=True)
# Continue with URL only if download fails
# Update progress: Saving to database (90%)
self.update_state(
state='PROGRESS',
meta={
'current_image': index,
'total_images': total_images,
'completed': completed,
'failed': failed,
'status': 'processing',
'current_image_id': image_id,
'current_image_progress': 90,
'results': results
}
)
# Log URL length for debugging (model field now supports up to 500 chars)
if image_url and len(image_url) > 500:
logger.error(f"[process_image_generation_queue] Image {image_id} - URL TOO LONG: {len(image_url)} chars (max 500). URL: {image_url[:150]}...")
logger.error(f"[process_image_generation_queue] Image {image_id} - CharField max_length=500 is too short! URL will be truncated.")
# Truncate to 500 chars if somehow longer (shouldn't happen, but safety check)
image_url = image_url[:500]
logger.warning(f"[process_image_generation_queue] Image {image_id} - Truncated URL length: {len(image_url)} chars")
elif image_url and len(image_url) > 200:
logger.info(f"[process_image_generation_queue] Image {image_id} - URL length {len(image_url)} chars (was limited to 200, now supports 500)")
try:
# Save file path and URL appropriately
if saved_file_path:
# Store local file path in image_path field
image.image_path = saved_file_path
# Also keep the original URL in image_url field for reference
if image_url:
image.image_url = image_url
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved local path: {saved_file_path}")
else:
# Only URL available, save to image_url
image.image_url = image_url
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved URL only: {image_url[:100] if image_url else 'None'}...")
image.status = 'generated'
# Determine which fields to update
update_fields = ['status']
if saved_file_path:
update_fields.append('image_path')
if image_url:
update_fields.append('image_url')
logger.info(f"[process_image_generation_queue] Image {image_id} - Attempting to save to database (fields: {update_fields})")
image.save(update_fields=update_fields)
logger.info(f"[process_image_generation_queue] Image {image_id} - Successfully saved to database")
except Exception as save_error:
error_str = str(save_error)
logger.error(f"[process_image_generation_queue] Image {image_id} - Database save FAILED: {error_str}", exc_info=True)
logger.error(f"[process_image_generation_queue] Image {image_id} - Error type: {type(save_error).__name__}")
# Continue even if save fails, but mark as failed in results
# Truncate error message to 180 chars to avoid same issue when saving error
truncated_error = error_str[:180] if len(error_str) > 180 else error_str
results.append({
'image_id': image_id,
'status': 'failed',
'error': f'Database save error: {truncated_error}'
})
failed += 1
else:
# Update progress: Complete (100%)
self.update_state(
state='PROGRESS',
meta={
'current_image': index,
'total_images': total_images,
'completed': completed + 1,
'failed': failed,
'status': 'processing',
'current_image_id': image_id,
'current_image_progress': 100,
'results': results + [{
'image_id': image_id,
'status': 'completed',
'image_url': image_url, # Original URL from API
'image_path': saved_file_path, # Local file path if saved
'revised_prompt': result.get('revised_prompt')
}]
}
)
results.append({
'image_id': image_id,
'status': 'completed',
'image_url': image_url, # Original URL from API
'image_path': saved_file_path, # Local file path if saved
'revised_prompt': result.get('revised_prompt')
})
completed += 1
except Exception as e:
logger.error(f"Error processing image {image_id}: {str(e)}", exc_info=True)
results.append({
'image_id': image_id,
'status': 'failed',
'error': str(e)
})
failed += 1
# Check if all images for the content are generated and update status to 'review'
if content_id and completed > 0:
try:
from igny8_core.business.content.models import Content, Images
content = Content.objects.get(id=content_id)
# Check if all images for this content are now generated
all_images = Images.objects.filter(content=content)
pending_images = all_images.filter(status='pending').count()
# If no pending images and content is still in draft, move to review
if pending_images == 0 and content.status == 'draft':
content.status = 'review'
content.save(update_fields=['status'])
logger.info(f"[process_image_generation_queue] Content #{content_id} status updated to 'review' (all images generated)")
except Exception as e:
logger.error(f"[process_image_generation_queue] Error updating content status: {str(e)}", exc_info=True)
# Final state
logger.info("=" * 80)
logger.info(f"process_image_generation_queue COMPLETED")
logger.info(f" - Total: {total_images}")
logger.info(f" - Completed: {completed}")
logger.info(f" - Failed: {failed}")
logger.info("=" * 80)
return {
'success': True,
'total_images': total_images,
'completed': completed,
'failed': failed,
'results': results
}