Files
seo/otoolkit_seo_content/models/seo_content_image.py
T

729 lines
28 KiB
Python

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'},
}
}