import base64 import io import json import logging import requests from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) try: from PIL import Image HAS_PILLOW = True except ImportError: HAS_PILLOW = False _logger.warning("Pillow not installed. Image optimization features will be disabled.") class OtkSeoContentImage(models.Model): _name = 'otk.seo.content.image' _description = 'SEO Content Generated Image' _order = 'sequence, id' content_id = fields.Many2one('otk.seo.content', 'Content', required=True, ondelete='cascade', index=True, help='The SEO content record this image belongs to.') sequence = fields.Integer('Sequence', default=10, help='Order in which images appear. Lower numbers appear first.') name = fields.Char('Image Name', help='A descriptive name for this image. Auto-generated from the prompt if not provided.') # === Generation Settings === prompt = fields.Text('Generation Prompt', required=True, help='The text description sent to the AI to generate this image. Be specific about style, subject, composition, and mood.') negative_prompt = fields.Text('Negative Prompt', default='text, watermark, low quality, blurry, distorted', help='Elements to avoid in the generated image. The AI will try not to include these aspects.') style = fields.Selection([ ('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('digital_art', 'Digital Art'), ('watercolor', 'Watercolor'), ('sketch', 'Sketch'), ('3d_render', '3D Render'), ], string='Style', default='photorealistic', help='Visual style for the generated image. Photorealistic creates photo-like images; other styles create artistic interpretations.') aspect_ratio = fields.Selection([ ('1024x1024', 'Square (1:1)'), ('1792x1024', 'Landscape (16:9)'), ('1024x1792', 'Portrait (9:16)'), ], string='Size', default='1792x1024', help='Image dimensions. Landscape (16:9) is ideal for blog headers; Square (1:1) works well for social media; Portrait (9:16) for mobile-first content.') quality = fields.Selection([ ('standard', 'Standard'), ('hd', 'HD'), ], string='Quality', default='standard', help='Image quality level. HD produces sharper images with more detail but uses more tokens.') # === Task Tracking === task_id = fields.Char('API Task ID', index=True, help='Internal identifier for tracking this image generation task in the O\'Toolkit API.') state = fields.Selection([ ('draft', 'Draft'), ('pending', 'Pending'), ('processing', 'Processing'), ('done', 'Done'), ('error', 'Error'), ], string='Status', default='draft', index=True, help='Current status of image generation. Draft: not submitted; Pending: waiting to be sent; Processing: AI is generating; Done: image ready; Error: generation failed.') # === Result === image = fields.Binary('Image', attachment=True, help='The generated image file. Stored as an Odoo attachment.') image_filename = fields.Char('Filename', help='The filename for the image when downloaded.') revised_prompt = fields.Text('Revised Prompt', help='The prompt as revised by the AI for better generation. The AI may modify your prompt to produce better results.') # === Optimization === image_original = fields.Binary('Original Image', attachment=True, help='The original unoptimized image. Kept as backup before optimization.') image_webp = fields.Binary('WebP Image', attachment=True, help='WebP version of the image for modern browsers. Smaller file size with same quality.') original_size = fields.Integer('Original Size (bytes)', help='File size of the original generated image before any optimization.') optimized_size = fields.Integer('Optimized Size (bytes)', help='File size after optimization. Lower is better for web performance.') webp_size = fields.Integer('WebP Size (bytes)', help='File size of the WebP version.') size_reduction = fields.Float('Size Reduction %', compute='_compute_size_reduction', help='Percentage reduction in file size from optimization.') width = fields.Integer('Width (px)', help='Image width in pixels.') height = fields.Integer('Height (px)', help='Image height in pixels.') is_optimized = fields.Boolean('Optimized', default=False, help='Indicates if this image has been optimized for web.') optimization_quality = fields.Integer('Optimization Quality', default=85, help='JPEG/WebP quality level (1-100). Lower means smaller files but less quality. 85 is a good balance.') # === Usage === alt_text = fields.Char('Alt Text', help='SEO-friendly alt text describing the image. Important for accessibility and search engine optimization.') is_cover = fields.Boolean('Use as Cover Image', help='If enabled, this image will be used as the cover/header image for the blog post.') is_og_image = fields.Boolean('Use as OpenGraph Image', help='If enabled, this image will be used for social media sharing previews (Facebook, Twitter, LinkedIn).') inserted_in_content = fields.Boolean('Inserted in Content', help='Indicates whether this image has been embedded within the content body.') # === Tracking === token_cost = fields.Integer('Tokens Used', help='Number of API tokens consumed to generate this image. HD quality images cost more tokens.') error_message = fields.Text('Error Message', help='If generation failed, this contains the error details. Check this to troubleshoot issues.') # === Retry Logic === retry_count = fields.Integer('Retry Count', default=0, help='Number of retry attempts made for this image generation.') max_retries = fields.Integer('Max Retries', default=3, help='Maximum number of retry attempts before permanently failing.') next_retry_at = fields.Datetime('Next Retry At', help='Scheduled time for next retry attempt.') processing_since = fields.Datetime('Processing Since', help='Timestamp of when processing started, for stuck task detection.') @api.model_create_multi def create(self, vals_list): """Auto-generate name if not provided.""" for vals in vals_list: if not vals.get('name') and vals.get('prompt'): vals['name'] = vals['prompt'][:50] + '...' if len(vals['prompt']) > 50 else vals['prompt'] return super().create(vals_list) def _get_api_config(self): """Get API configuration.""" ICP = self.env['ir.config_parameter'].sudo() base_url = ICP.get_param('otoolkit.api.endpoint', '') api_key = ICP.get_param('otoolkit_api_key', '') if not api_key: raise UserError(_("Please configure your O'Toolkit API key in Settings.")) if not base_url: raise UserError(_("O'Toolkit API endpoint is not configured.")) return base_url, api_key def action_generate(self): """Submit image for generation.""" self.ensure_one() if not self.prompt: raise UserError(_("Please provide a generation prompt.")) base_url, api_key = self._get_api_config() headers = { 'Content-Type': 'application/json', 'Odoo-Api-Key': api_key, } payload = { 'prompt': self.prompt, 'style': self.style, 'size': self.aspect_ratio, 'quality': self.quality, 'odoo_user_id': self.env.uid, } if self.negative_prompt: payload['negative_prompt'] = self.negative_prompt try: response = requests.post( f"{base_url.rstrip('/')}/api/seo-content/image/", headers=headers, json=payload, timeout=30 ) if response.status_code == 400: try: error_data = response.json() error_msg = error_data.get('error') or error_data.get('detail') or 'Unknown error' except (ValueError, json.JSONDecodeError): error_msg = response.text[:500] if response.text else 'Unknown error' raise UserError(_("API Error: %s") % error_msg) if response.status_code >= 400: raise UserError(_("API Error (%s): %s") % (response.status_code, response.text[:500])) response.raise_for_status() try: result = response.json() except (ValueError, json.JSONDecodeError): raise UserError(_("Invalid response from API: %s") % response.text[:200]) if result.get('success') and result.get('task_id'): self.write({ 'task_id': result['task_id'], 'state': 'processing', 'error_message': False, 'processing_since': fields.Datetime.now(), }) else: raise UserError(_("Failed to submit image generation: %s") % result.get('error', 'Unknown error')) except requests.exceptions.RequestException as e: _logger.error(f"Image generation request error: {e}") self.write({ 'state': 'error', 'error_message': str(e), }) raise UserError(_("Failed to submit image generation: %s") % str(e)) def action_retry(self): """Retry failed image generation.""" self.ensure_one() self.write({ 'state': 'pending', 'error_message': False, 'task_id': False, }) return self.action_generate() def action_regenerate(self): """Regenerate image with current settings (allows modifying prompt/style first).""" self.ensure_one() if self.state not in ('done', 'error'): raise UserError(_("Can only regenerate completed or failed images.")) # Store current image as backup (original) if we have an image and no backup yet if self.image and not self.image_original: self.image_original = self.image # Reset state and clear generation results self.write({ 'state': 'pending', 'task_id': False, 'error_message': False, 'image': False, 'image_webp': False, 'revised_prompt': False, 'is_optimized': False, 'optimized_size': 0, 'webp_size': 0, 'token_cost': 0, }) # Submit for generation return self.action_generate() def action_poll_status(self): """Check the status of image generation.""" self.ensure_one() if not self.task_id: raise UserError(_("No task ID found. Please generate the image first.")) base_url, api_key = self._get_api_config() headers = { 'Content-Type': 'application/json', 'Odoo-Api-Key': api_key, } # Safely convert task_id to int try: task_id_int = int(self.task_id) except (ValueError, TypeError): raise UserError(_("Invalid task ID format: %s") % self.task_id) try: response = requests.post( f"{base_url.rstrip('/')}/api/tasks/", headers=headers, json={'task_ids': [task_id_int]}, timeout=30 ) response.raise_for_status() try: data = response.json() except (ValueError, json.JSONDecodeError): _logger.error("Invalid JSON response from tasks API: %s", response.text[:200]) return tasks = data.get('tasks', []) if isinstance(data, dict) else data if tasks and len(tasks) > 0: task = tasks[0] status = task.get('status') if status == 'completed': result = task.get('result', {}) self._process_completed_image(result) elif status == 'failed': error_msg = task.get('result', {}).get('error', 'Generation failed') error_type = task.get('result', {}).get('type', '') # Retry on transient errors if error_type in ('rate_limit', 'timeout', 'connection_error'): self._schedule_retry(error_msg) else: self.write({ 'state': 'error', 'error_message': error_msg, }) # Notify user if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id: self.env['bus.bus']._sendone( self.content_id.create_uid.partner_id, 'simple_notification', { 'title': _("Image Generation Failed"), 'message': _("An image for '%s' could not be generated.") % ( self.content_id.generated_title or self.content_id.name), 'type': 'warning', 'sticky': False, } ) self._check_content_images_complete() # else: still processing, do nothing except requests.exceptions.RequestException as e: _logger.error("Image status poll error: %s", e) def _process_completed_image(self, result): """Process a completed image generation result.""" self.ensure_one() image_base64 = result.get('image_base64') if not image_base64: self.write({ 'state': 'error', 'error_message': 'No image data received', }) return # Parse size from aspect_ratio size_parts = self.aspect_ratio.split('x') width = int(size_parts[0]) if len(size_parts) == 2 else 1024 height = int(size_parts[1]) if len(size_parts) == 2 else 1024 # Generate alt text if not set alt_text = self.alt_text if not alt_text and self.content_id: alt_text = f"{self.content_id.generated_title or self.content_id.name} - Image" self.write({ 'image': image_base64, 'image_filename': f"seo_image_{self.id}.png", 'revised_prompt': result.get('revised_prompt', ''), 'width': width, 'height': height, 'token_cost': result.get('cost', 0), 'state': 'done', 'error_message': False, 'alt_text': alt_text, }) # Notify user of individual image completion if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id: total = len(self.content_id.image_ids) done = len(self.content_id.image_ids.filtered(lambda i: i.state == 'done')) self.env['bus.bus']._sendone( self.content_id.create_uid.partner_id, 'simple_notification', { 'title': _("Image Generated"), 'message': _("Image %d/%d for '%s' is ready.") % ( done, total, self.content_id.generated_title or self.content_id.name), 'type': 'info', 'sticky': False, } ) # Update content state if all images are done self._check_content_images_complete() def _schedule_retry(self, error_msg): """Schedule a retry with exponential backoff, or permanently fail.""" self.ensure_one() backoff_minutes = [1, 5, 15] if self.retry_count >= self.max_retries: self.write({ 'state': 'error', 'error_message': _("Permanently failed after %d retries. Last error: %s") % ( self.retry_count, error_msg), 'processing_since': False, }) _logger.warning("Image %s permanently failed after %d retries", self.id, self.retry_count) # Notify user of permanent failure if self.content_id and self.content_id.create_uid and self.content_id.create_uid.partner_id: self.env['bus.bus']._sendone( self.content_id.create_uid.partner_id, 'simple_notification', { 'title': _("Image Generation Failed"), 'message': _("An image for '%s' failed after %d retries.") % ( self.content_id.generated_title or self.content_id.name, self.retry_count), 'type': 'warning', 'sticky': True, } ) self._check_content_images_complete() return from datetime import timedelta delay = backoff_minutes[min(self.retry_count, len(backoff_minutes) - 1)] next_retry = fields.Datetime.now() + timedelta(minutes=delay) self.write({ 'state': 'pending', 'retry_count': self.retry_count + 1, 'next_retry_at': next_retry, 'error_message': _("Retry %d/%d scheduled. Error: %s") % ( self.retry_count + 1, self.max_retries, error_msg), 'processing_since': False, 'task_id': False, }) _logger.info("Image %s retry %d/%d scheduled at %s", self.id, self.retry_count, self.max_retries, next_retry) def _check_content_images_complete(self): """Check if all images for the content are complete. Transitions content to 'review' when no images are pending/processing, regardless of whether they are 'done' or 'error'. """ if not self.content_id: return content = self.content_id pending_images = content.image_ids.filtered( lambda i: i.state in ('pending', 'processing') ) if not pending_images and content.state == 'images_pending': content.write({'state': 'review'}) # Send bus notification if content.create_uid and content.create_uid.partner_id: self.env['bus.bus']._sendone( content.create_uid.partner_id, 'simple_notification', { 'title': _("Images Ready!"), 'message': _("All images for '%s' are ready.") % ( content.generated_title or content.name), 'type': 'success', 'sticky': False, } ) @api.model def cron_submit_pending_images(self): """Cron job to submit pending images for generation. Also picks up images with pending retries. """ from datetime import timedelta now = fields.Datetime.now() # Include images with pending retries (next_retry_at <= now) pending = self.search([ '|', '&', ('state', '=', 'pending'), ('next_retry_at', '=', False), '&', ('state', '=', 'pending'), ('next_retry_at', '<=', now), ], limit=10) for image in pending: try: image.write({'next_retry_at': False}) image.action_generate() except Exception as e: _logger.error(f"Failed to submit image {image.id}: {e}") image.write({ 'state': 'error', 'error_message': str(e), }) @api.model def cron_poll_image_status(self): """Cron job to poll status of processing images. Also handles stuck images (processing > 30 min) and retries on failure. """ from datetime import timedelta now = fields.Datetime.now() # --- Detect stuck images (processing for > 30 minutes) --- stuck_cutoff = now - timedelta(minutes=30) stuck_images = self.search([ ('state', '=', 'processing'), ('processing_since', '!=', False), ('processing_since', '<', stuck_cutoff), ], limit=10) for img in stuck_images: _logger.warning("Image %s stuck in processing since %s, scheduling retry", img.id, img.processing_since) img._schedule_retry(_("Task stuck for over 30 minutes")) # --- Poll processing tasks --- processing = self.search([ ('state', '=', 'processing'), ('task_id', '!=', False), ], limit=20) if not processing: return # Batch poll all tasks - safely convert task_ids to int task_ids = [] task_id_map = {} # Maps int task_id back to image for img in processing: if img.task_id: try: task_id_int = int(img.task_id) task_ids.append(task_id_int) task_id_map[str(task_id_int)] = img except (ValueError, TypeError): _logger.warning("Invalid task_id format for image %s: %s", img.id, img.task_id) img.write({ 'state': 'error', 'error_message': f'Invalid task ID format: {img.task_id}', }) if not task_ids: return try: base_url, api_key = processing[0]._get_api_config() headers = { 'Content-Type': 'application/json', 'Odoo-Api-Key': api_key, } response = requests.post( f"{base_url.rstrip('/')}/api/tasks/", headers=headers, json={'task_ids': task_ids}, timeout=30 ) response.raise_for_status() try: data = response.json() except (ValueError, json.JSONDecodeError): _logger.error("Invalid JSON response from tasks API in cron: %s", response.text[:200]) return tasks = data.get('tasks', []) if isinstance(data, dict) else data # Create mapping of task_id to result task_result_map = {str(t.get('id')): t for t in tasks if t.get('id')} for task_id_str, image in task_id_map.items(): task = task_result_map.get(task_id_str) if not task: continue status = task.get('status') if status == 'completed': result = task.get('result', {}) image._process_completed_image(result) elif status == 'failed': error_msg = task.get('result', {}).get('error', 'Generation failed') image._schedule_retry(error_msg) except Exception as e: _logger.error("Image status poll cron error: %s", e) @api.depends('original_size', 'optimized_size') def _compute_size_reduction(self): for record in self: if record.original_size and record.optimized_size: record.size_reduction = round( (1 - record.optimized_size / record.original_size) * 100, 1 ) else: record.size_reduction = 0 def action_optimize(self): """Optimize image for web - compress and create WebP version.""" self.ensure_one() if not HAS_PILLOW: raise UserError(_("Image optimization requires Pillow library. Please install it with: pip install Pillow")) if not self.image: raise UserError(_("No image to optimize.")) try: # Decode original image image_data = base64.b64decode(self.image) original_size = len(image_data) # Store original if not already stored if not self.image_original: self.image_original = self.image # Open with Pillow img = Image.open(io.BytesIO(image_data)) # Get actual dimensions width, height = img.size # Convert to RGB if necessary (for JPEG/WebP compatibility) if img.mode in ('RGBA', 'P'): # Create white background for transparency background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') background.paste(img, mask=img.split()[3] if len(img.split()) > 3 else None) img = background elif img.mode != 'RGB': img = img.convert('RGB') quality = self.optimization_quality or 85 # Optimize as JPEG jpeg_buffer = io.BytesIO() img.save(jpeg_buffer, format='JPEG', quality=quality, optimize=True) optimized_jpeg = jpeg_buffer.getvalue() optimized_size = len(optimized_jpeg) # Try to create WebP version (requires libwebp) webp_data = None webp_size = 0 try: webp_buffer = io.BytesIO() img.save(webp_buffer, format='WEBP', quality=quality) webp_data = webp_buffer.getvalue() webp_size = len(webp_data) except Exception as webp_error: _logger.warning(f"WebP conversion not available: {webp_error}") # Update record update_vals = { 'image': base64.b64encode(optimized_jpeg), 'image_filename': f"seo_image_{self.id}.jpg", 'original_size': original_size, 'optimized_size': optimized_size, 'webp_size': webp_size, 'width': width, 'height': height, 'is_optimized': True, } if webp_data: update_vals['image_webp'] = base64.b64encode(webp_data) self.write(update_vals) _logger.info( f"Image {self.id} optimized: {original_size} -> {optimized_size} bytes " f"({round((1 - optimized_size/original_size) * 100, 1)}% reduction)" + (f", WebP: {webp_size} bytes" if webp_size else "") ) message = _('Reduced from %s KB to %s KB (%.1f%% smaller)') % ( round(original_size / 1024, 1), round(optimized_size / 1024, 1), (1 - optimized_size / original_size) * 100, ) if webp_size: message += _('. WebP: %s KB') % round(webp_size / 1024, 1) except Exception as e: _logger.error(f"Image optimization failed: {e}") raise UserError(_("Image optimization failed: %s") % str(e)) def action_restore_original(self): """Restore the original unoptimized image.""" self.ensure_one() if not self.image_original: raise UserError(_("No original image stored.")) self.write({ 'image': self.image_original, 'image_filename': f"seo_image_{self.id}.png", 'is_optimized': False, 'optimized_size': 0, 'image_webp': False, 'webp_size': 0, }) def action_auto_optimize_all(self): """Optimize all non-optimized images.""" images = self.search([ ('state', '=', 'done'), ('is_optimized', '=', False), ('image', '!=', False), ]) optimized_count = 0 for image in images: try: image.action_optimize() optimized_count += 1 except Exception as e: _logger.error(f"Failed to optimize image {image.id}: {e}") return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Batch Optimization Complete'), 'message': _('%d images optimized.') % optimized_count, 'type': 'success', 'sticky': False, 'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'}, } }