Files

1923 lines
81 KiB
Python

import ast
import difflib
import json
import logging
import re
import requests
import time
import jwt
from bs4 import BeautifulSoup
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import html2plaintext
_logger = logging.getLogger(__name__)
# === Input Validation Constants ===
MAX_KEYWORDS_LENGTH = 500
MAX_TOPIC_LENGTH = 5000
MAX_SOURCE_TEXT_LENGTH = 50000
MAX_CUSTOM_INSTRUCTIONS_LENGTH = 2000
MAX_REFERENCES = 5
class OtkSeoContent(models.Model):
_name = 'otk.seo.content'
_description = 'SEO Content'
_inherit = ['mail.thread', 'website.seo.metadata']
_order = 'create_date desc'
# === Identification ===
name = fields.Char('Title', required=True, tracking=True,
help='The main title for your content. This will be used as the default title if no AI-generated title is created.')
content_type = fields.Selection([
('blog_post', 'Blog Post'),
('product_desc', 'Product Description'),
('category_desc', 'Category Description'),
('landing_page', 'Landing Page Content'),
('social_post', 'Social Media Post'),
], string='Content Type', required=True, default='blog_post', tracking=True,
help='The type of content to generate. Each type has different formatting and structure optimized for its purpose.')
# === Source Information ===
source_keywords = fields.Char('Target Keywords',
help='Comma-separated keywords to target in the content')
source_topic = fields.Text('Topic/Brief',
help='Description of what the content should be about')
source_product_id = fields.Many2one('product.template', 'Source Product',
help='Generate content based on this product')
source_text = fields.Text('Source Content',
help='Existing content to optimize or use as reference')
# === Generation Settings ===
tone = fields.Selection([
('professional', 'Professional'),
('casual', 'Casual'),
('technical', 'Technical'),
('persuasive', 'Persuasive'),
('conversational', 'Conversational'),
], string='Tone', default='professional',
help='The writing style and voice for the generated content. Professional is formal and business-like, Casual is friendly and relaxed, Technical is detailed and precise, Persuasive is sales-oriented, and Conversational is engaging and personal.')
target_word_count = fields.Selection([
('short', 'Short (300-500 words)'),
('medium', 'Medium (500-1000 words)'),
('long', 'Long (1000-2000 words)'),
], string='Target Length', default='medium',
help='The approximate length of the generated content. Longer content typically ranks better for SEO but requires more tokens.')
language_id = fields.Many2one('res.lang', 'Language',
default=lambda self: self._default_language(),
help='The language in which the content will be generated. The AI will write in this language.')
template_id = fields.Many2one('otk.seo.template', 'Content Template',
domain="[('content_type', '=', content_type)]",
help='A predefined template that includes system prompts, formatting rules, and default settings. Templates help ensure consistent content structure and style.')
brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
default=lambda self: self.env['otk.seo.brand.voice'].get_default_voice(),
help='Brand voice guidelines to apply to generated content. Defines tone, style, vocabulary, and other brand-specific rules.')
# === References ===
reference_ids = fields.Many2many('otk.seo.reference',
'otk_seo_content_reference_rel', 'content_id', 'reference_id',
string='References',
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]",
help='Reference materials to provide context and inspiration for content generation. Max 5 references.')
# === Generated Content ===
generated_title = fields.Char('Generated Title', translate=True,
help='The AI-generated title optimized for SEO. You can edit this before publishing.')
generated_subtitle = fields.Char('Generated Subtitle', translate=True,
help='A subtitle or tagline generated by the AI to complement the main title.')
generated_content = fields.Html('Generated Content', sanitize=True, translate=True,
help='The main body of the AI-generated content in HTML format. This includes headings, paragraphs, lists, and other formatting.')
generated_teaser = fields.Text('Generated Teaser', translate=True,
help='A short excerpt or summary of the content, typically used for blog post previews and social sharing.')
generated_keywords = fields.Char('Suggested Keywords', translate=True,
help='Additional keywords suggested by the AI based on the generated content. Use these to improve your SEO strategy.')
# Note: SEO fields come from website.seo.metadata:
# - website_meta_title
# - website_meta_description
# - website_meta_keywords
# - website_meta_og_img
# - seo_name
# === SEO Analysis ===
seo_score = fields.Integer('SEO Score', compute='_compute_seo_score', store=True,
help='Overall SEO score from 0-100 based on title length, meta description, keyword density, content length, header structure, and image optimization.')
keyword_density = fields.Float('Keyword Density %', digits=(5, 2),
help='Percentage of target keywords in the content. Optimal range is 1-2.5%. Too low means keywords are underused; too high may be seen as keyword stuffing.')
readability_score = fields.Char('Readability',
help='Assessment of how easy the content is to read, based on sentence length, vocabulary complexity, and structure.')
seo_analysis = fields.Text('SEO Analysis Details',
help='Detailed JSON breakdown of SEO scoring for each factor: title, meta description, keywords, content length, headers, and images.')
seo_analysis_html = fields.Html('SEO Analysis Display', compute='_compute_seo_analysis_html',
sanitize=False, help='User-friendly display of SEO analysis with actionable advice.')
seo_improvements = fields.Text('Improvement Suggestions', compute='_compute_seo_analysis_html',
help='List of actionable improvements to boost SEO score.')
word_count = fields.Integer('Word Count', compute='_compute_word_count', store=True,
help='Total number of words in the generated content. Longer content (1000+ words) typically performs better in search rankings.')
# === Meta Length Indicators ===
meta_title_length = fields.Integer('Title Length', compute='_compute_meta_lengths',
help='Character count of the meta title.')
meta_desc_length = fields.Integer('Description Length', compute='_compute_meta_lengths',
help='Character count of the meta description.')
meta_title_status = fields.Selection([
('short', 'Too Short'),
('optimal', 'Optimal'),
('long', 'Too Long'),
], string='Title Status', compute='_compute_meta_lengths',
help='Whether the meta title length is optimal for SEO.')
meta_desc_status = fields.Selection([
('short', 'Too Short'),
('optimal', 'Optimal'),
('long', 'Too Long'),
], string='Description Status', compute='_compute_meta_lengths',
help='Whether the meta description length is optimal for SEO.')
# === Images ===
image_ids = fields.One2many('otk.seo.content.image', 'content_id', 'Generated Images',
help='AI-generated images for this content. Images are generated asynchronously and will appear here when ready.')
image_count = fields.Integer('Image Count', compute='_compute_image_count',
help='Total number of images associated with this content.')
images_pending = fields.Boolean('Images Pending', compute='_compute_images_pending',
help='Indicates if there are images still being generated or waiting to be processed.')
has_insertable_images = fields.Boolean('Has Insertable Images', compute='_compute_has_insertable_images',
help='True if there are completed non-cover images that have not yet been inserted into content.')
requested_image_count = fields.Integer('Requested Images', default=1,
help='Number of images to generate for this content. The first image will be used as the cover image.')
image_style = fields.Selection([
('photorealistic', 'Photorealistic'),
('illustration', 'Illustration'),
('digital_art', 'Digital Art'),
('watercolor', 'Watercolor'),
('sketch', 'Sketch'),
('3d_render', '3D Render'),
], string='Image Style', default='photorealistic',
help='Visual style for generated images. Applied to all images created for this content.')
# === Workflow ===
state = fields.Selection([
('draft', 'Draft'),
('generating', 'Generating'),
('images_pending', 'Waiting for Images'),
('review', 'Review'),
('approved', 'Approved'),
('published', 'Published'),
('archived', 'Archived'),
], string='Status', default='draft', tracking=True, copy=False, index=True,
help='Current status in the content workflow. Draft: initial state; Generating: AI is creating content; Waiting for Images: content done, images processing; Review: ready for review; Approved: approved for publishing; Published: blog post created; Archived: no longer active.')
# === Output ===
blog_post_id = fields.Many2one('blog.post', 'Created Blog Post', readonly=True, copy=False,
help='The blog post created from this content. Click to view or edit the published post.')
blog_id = fields.Many2one('blog.blog', 'Target Blog',
help='The blog where the post will be published. If not set, the default blog will be used.')
# === Batch Processing ===
batch_id = fields.Many2one('otk.seo.content.batch', 'Batch Job', ondelete='set null',
help='The batch job that created this content, if generated as part of a bulk operation.')
# === Tracking ===
token_cost = fields.Integer('Tokens Used', readonly=True, store=True,
help='Number of API tokens consumed for generating this content. This affects your O\'Toolkit credit balance.')
generation_time = fields.Float('Generation Time (s)', readonly=True, store=True,
help='Time taken to generate the content in seconds. Useful for performance monitoring.')
error_message = fields.Text('Error Message',
help='If generation failed, this contains the error details. Check this to troubleshoot issues.')
api_task_id = fields.Char('API Task ID',
help='Internal identifier for tracking the generation task in the O\'Toolkit API.')
# === Retry Logic ===
retry_count = fields.Integer('Retry Count', default=0,
help='Number of retry attempts made for this content 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.')
generating_since = fields.Datetime('Generating Since',
help='Timestamp of when generation started, for stuck task detection.')
# === Content Freshness ===
last_refreshed_date = fields.Date('Last Refreshed',
default=fields.Date.today,
help='The date when this content was last reviewed or updated. Used to track content freshness.')
refresh_threshold_days = fields.Integer('Refresh Threshold (days)',
default=180,
help='Number of days after which content should be reviewed for freshness. Default is 180 days (6 months).')
is_stale = fields.Boolean('Needs Refresh', compute='_compute_freshness', store=True,
help='Indicates if the content has exceeded its refresh threshold and should be reviewed.')
days_since_refresh = fields.Integer('Days Since Refresh', compute='_compute_freshness', store=True,
help='Number of days since the content was last refreshed.')
refresh_alert_sent = fields.Boolean('Refresh Alert Sent', default=False,
help='Indicates if a refresh alert has already been sent for this content.')
# === Version History ===
version_ids = fields.One2many('otk.seo.content.version', 'content_id', 'Versions', copy=False,
help='Previous versions of this content. You can restore any version if needed.')
current_version = fields.Integer('Current Version', default=1, copy=False,
help='The current version number. Incremented each time content is regenerated or restored.')
@api.constrains('requested_image_count')
def _check_image_count_range(self):
for record in self:
if not (0 <= record.requested_image_count <= 10):
raise ValidationError(_('Image count must be between 0 and 10.'))
def _default_language(self):
"""Get default language for content generation."""
lang_code = self.env.lang or 'en_US'
lang = self.env['res.lang']._lang_get(lang_code)
return lang.id if lang else False
@api.onchange('template_id')
def _onchange_template_id_references(self):
"""Inherit references from template when selected."""
if self.template_id and self.template_id.reference_ids:
# Add template references (user can still modify)
self.reference_ids = [(6, 0, self.template_id.reference_ids.ids)]
@api.depends('last_refreshed_date', 'refresh_threshold_days')
def _compute_freshness(self):
"""Compute content freshness based on last refresh date."""
today = fields.Date.today()
for record in self:
if record.last_refreshed_date:
delta = today - record.last_refreshed_date
record.days_since_refresh = delta.days
record.is_stale = delta.days >= (record.refresh_threshold_days or 180)
else:
record.days_since_refresh = 0
record.is_stale = False
@api.depends('generated_content')
def _compute_word_count(self):
for record in self:
if record.generated_content:
text = html2plaintext(record.generated_content)
record.word_count = len(text.split())
else:
record.word_count = 0
@api.depends('website_meta_title', 'website_meta_description')
def _compute_meta_lengths(self):
for record in self:
# Title length and status
title_len = len(record.website_meta_title or '')
record.meta_title_length = title_len
if title_len == 0:
record.meta_title_status = 'short'
elif 30 <= title_len <= 60:
record.meta_title_status = 'optimal'
elif title_len < 30:
record.meta_title_status = 'short'
else:
record.meta_title_status = 'long'
# Description length and status
desc_len = len(record.website_meta_description or '')
record.meta_desc_length = desc_len
if desc_len == 0:
record.meta_desc_status = 'short'
elif 120 <= desc_len <= 155:
record.meta_desc_status = 'optimal'
elif desc_len < 120:
record.meta_desc_status = 'short'
else:
record.meta_desc_status = 'long'
@api.depends('image_ids')
def _compute_image_count(self):
for record in self:
record.image_count = len(record.image_ids)
@api.depends('image_ids.state')
def _compute_images_pending(self):
for record in self:
record.images_pending = any(
img.state in ('pending', 'processing')
for img in record.image_ids
)
@api.depends('image_ids', 'image_ids.state', 'image_ids.is_cover', 'image_ids.inserted_in_content')
def _compute_has_insertable_images(self):
for record in self:
record.has_insertable_images = any(
img.state == 'done' and not img.is_cover and not img.inserted_in_content
for img in record.image_ids
)
@api.depends('generated_content', 'website_meta_title', 'website_meta_description',
'website_meta_keywords', 'source_keywords', 'generated_title',
'image_ids.state', 'image_ids.alt_text')
def _compute_seo_score(self):
for record in self:
score = 0
analysis = {}
# Title optimization (15 points)
if record.website_meta_title:
title_len = len(record.website_meta_title)
if 30 <= title_len <= 60:
score += 15
analysis['title'] = {'score': 15, 'status': 'optimal', 'length': title_len}
elif 20 <= title_len < 30 or 60 < title_len <= 70:
score += 8
analysis['title'] = {'score': 8, 'status': 'acceptable', 'length': title_len}
else:
analysis['title'] = {'score': 0, 'status': 'needs_work', 'length': title_len}
else:
analysis['title'] = {'score': 0, 'status': 'missing'}
# Meta description (15 points)
if record.website_meta_description:
desc_len = len(record.website_meta_description)
if 120 <= desc_len <= 155:
score += 15
analysis['meta_desc'] = {'score': 15, 'status': 'optimal', 'length': desc_len}
elif 70 <= desc_len < 120 or 155 < desc_len <= 160:
score += 8
analysis['meta_desc'] = {'score': 8, 'status': 'acceptable', 'length': desc_len}
else:
analysis['meta_desc'] = {'score': 0, 'status': 'needs_work', 'length': desc_len}
else:
analysis['meta_desc'] = {'score': 0, 'status': 'missing'}
# Meta keywords (10 points)
if record.website_meta_keywords:
keywords = [k.strip() for k in record.website_meta_keywords.split(',') if k.strip()]
keyword_count = len(keywords)
if 3 <= keyword_count <= 10:
score += 10
analysis['meta_keywords'] = {'score': 10, 'status': 'optimal', 'count': keyword_count}
elif 1 <= keyword_count < 3 or 10 < keyword_count <= 15:
score += 5
analysis['meta_keywords'] = {'score': 5, 'status': 'acceptable', 'count': keyword_count}
else:
analysis['meta_keywords'] = {'score': 0, 'status': 'too_many', 'count': keyword_count}
else:
analysis['meta_keywords'] = {'score': 0, 'status': 'missing'}
# Cache plaintext conversion (used by multiple checks below)
_plain_text = html2plaintext(record.generated_content) if record.generated_content else ''
_plain_text_lower = _plain_text.lower() if _plain_text else ''
_plain_word_count = len(_plain_text.split()) if _plain_text else 0
# Keyword usage in content (20 points)
if record.source_keywords and _plain_text:
keywords = [k.strip().lower() for k in record.source_keywords.split(',')]
keyword_count = sum(_plain_text_lower.count(kw) for kw in keywords if kw)
if _plain_word_count > 0:
density = (keyword_count / _plain_word_count) * 100
record.keyword_density = round(density, 2)
if 1 <= density <= 2.5:
score += 20
analysis['keywords'] = {'score': 20, 'status': 'optimal', 'density': density}
elif 0.5 <= density < 1 or 2.5 < density <= 3:
score += 10
analysis['keywords'] = {'score': 10, 'status': 'acceptable', 'density': density}
else:
analysis['keywords'] = {'score': 0, 'status': 'needs_work', 'density': density}
else:
analysis['keywords'] = {'score': 0, 'status': 'no_content'}
else:
analysis['keywords'] = {'score': 0, 'status': 'missing'}
# Content length (20 points)
if _plain_text:
wc = _plain_word_count
if wc >= 1000:
score += 20
analysis['content_length'] = {'score': 20, 'status': 'optimal', 'words': wc}
elif wc >= 500:
score += 15
analysis['content_length'] = {'score': 15, 'status': 'good', 'words': wc}
elif wc >= 300:
score += 10
analysis['content_length'] = {'score': 10, 'status': 'acceptable', 'words': wc}
else:
analysis['content_length'] = {'score': 0, 'status': 'too_short', 'words': wc}
else:
analysis['content_length'] = {'score': 0, 'status': 'no_content'}
# Header structure (10 points)
if record.generated_content:
content_lower = record.generated_content.lower()
has_h2 = '<h2' in content_lower
has_h3 = '<h3' in content_lower
if has_h2 and has_h3:
score += 10
analysis['headers'] = {'score': 10, 'status': 'optimal', 'h2': has_h2, 'h3': has_h3}
elif has_h2:
score += 5
analysis['headers'] = {'score': 5, 'status': 'acceptable', 'h2': has_h2, 'h3': has_h3}
else:
analysis['headers'] = {'score': 0, 'status': 'missing', 'h2': has_h2, 'h3': has_h3}
else:
analysis['headers'] = {'score': 0, 'status': 'no_content'}
# Images (10 points)
done_images = record.image_ids.filtered(lambda i: i.state == 'done')
images_with_alt = done_images.filtered(lambda i: i.alt_text)
if images_with_alt:
score += 10
analysis['images'] = {'score': 10, 'status': 'optimal', 'count': len(done_images), 'with_alt': len(images_with_alt)}
elif done_images:
score += 5
analysis['images'] = {'score': 5, 'status': 'missing_alt', 'count': len(done_images), 'with_alt': 0}
else:
analysis['images'] = {'score': 0, 'status': 'no_images', 'count': 0}
record.seo_score = score
record.seo_analysis = analysis # fields.Json expects a dict, not a string
@api.depends('seo_analysis', 'seo_score')
def _compute_seo_analysis_html(self):
"""Generate user-friendly HTML display of SEO analysis."""
for record in self:
if not record.seo_analysis:
record.seo_analysis_html = '''
<div class="text-muted text-center p-4">
<i class="fa fa-info-circle fa-2x mb-2"></i>
<p>Generate content to see SEO analysis.</p>
</div>
'''
record.seo_improvements = ''
continue
# Parse analysis
analysis = ast.literal_eval(record.seo_analysis or "{}")
score = record.seo_score or 0
improvements = []
# Build HTML
html_parts = []
# Score overview card
score_color = 'success' if score >= 70 else ('warning' if score >= 40 else 'danger')
score_icon = 'check-circle' if score >= 70 else ('exclamation-triangle' if score >= 40 else 'times-circle')
score_desc = record._get_score_description(score)
html_parts.append(f'''
<div class="alert alert-{score_color} d-flex align-items-center mb-4" role="alert">
<div class="me-3" style="font-size: 2.5rem; font-weight: bold; min-width: 80px; text-align: center;">
{score}<small style="font-size: 0.5em">/100</small>
</div>
<div>
<strong><i class="fa fa-{score_icon}"></i> {score_desc['title']}</strong>
<p class="mb-0 small">{score_desc['message']}</p>
</div>
</div>
''')
# Analysis sections
sections = [
('title', 'Meta Title', 'fa-heading', 15),
('meta_desc', 'Meta Description', 'fa-align-left', 15),
('meta_keywords', 'Meta Keywords', 'fa-tags', 10),
('keywords', 'Keyword Density', 'fa-key', 20),
('content_length', 'Content Length', 'fa-file-text-o', 20),
('headers', 'Header Structure', 'fa-list-ol', 10),
('images', 'Images', 'fa-image', 10),
]
html_parts.append('<div class="row">')
for key, label, icon, max_score in sections:
section_data = analysis.get(key, {})
section_score = section_data.get('score', 0)
# Determine styling
if section_score == max_score:
bg_class = 'bg-success-subtle border-success'
text_class = 'text-success'
status_icon = 'fa-check-circle'
elif section_score > 0:
bg_class = 'bg-warning-subtle border-warning'
text_class = 'text-warning'
status_icon = 'fa-exclamation-circle'
else:
bg_class = 'bg-danger-subtle border-danger'
text_class = 'text-danger'
status_icon = 'fa-times-circle'
# Get advice
advice = record._get_seo_advice(key, section_data, max_score)
if advice and section_score < max_score:
improvements.append(advice)
# Get details
details = record._get_section_details(key, section_data)
html_parts.append(f'''
<div class="col-md-6 col-lg-4 mb-3">
<div class="card h-100 border {bg_class}">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="fw-bold">
<i class="fa {icon} me-1"></i> {label}
</span>
<span class="badge bg-{'success' if section_score == max_score else ('warning' if section_score > 0 else 'danger')}">
{section_score}/{max_score}
</span>
</div>
<div class="{text_class} small">
<i class="fa {status_icon}"></i>
{advice if advice else '<span class="text-success">Excellent!</span>'}
</div>
{f'<div class="text-muted small mt-2 border-top pt-2">{details}</div>' if details else ''}
</div>
</div>
</div>
''')
html_parts.append('</div>')
record.seo_analysis_html = ''.join(html_parts)
record.seo_improvements = '\n'.join(f"• {imp}" for imp in improvements) if improvements else ''
def _get_score_description(self, score):
"""Get title and message based on score."""
if score >= 80:
return {
'title': 'Excellent SEO',
'message': 'Your content is well-optimized for search engines. Great job!'
}
elif score >= 60:
return {
'title': 'Good SEO',
'message': 'Your content is fairly optimized but has room for improvement.'
}
elif score >= 40:
return {
'title': 'Needs Improvement',
'message': 'Your content needs work to rank well. Follow the suggestions below.'
}
else:
return {
'title': 'Poor SEO',
'message': 'Significant improvements needed. Address the issues highlighted below.'
}
def _get_seo_advice(self, key, data, max_score):
"""Get actionable advice for each SEO factor."""
status = data.get('status', 'missing')
current_score = data.get('score', 0)
if current_score == max_score:
return None # No advice needed
advice_map = {
'title': {
'missing': 'Add a meta title (30-60 characters recommended)',
'needs_work': f"Optimize title length ({data.get('length', 0)} chars) - aim for 30-60 characters",
'acceptable': f"Title is {data.get('length', 0)} chars - optimal is 30-60 characters",
},
'meta_desc': {
'missing': 'Add a meta description (120-155 characters recommended)',
'needs_work': f"Adjust description length ({data.get('length', 0)} chars) - aim for 120-155 characters",
'acceptable': f"Description is {data.get('length', 0)} chars - optimal is 120-155 characters",
},
'meta_keywords': {
'missing': 'Add 3-10 meta keywords separated by commas',
'too_many': f"Reduce keywords from {data.get('count', 0)} to 3-10 for best results",
'acceptable': f"Add more keywords (currently {data.get('count', 0)}) - aim for 3-10",
},
'keywords': {
'missing': 'Add target keywords to analyze density',
'no_content': 'Generate content to analyze keyword usage',
'needs_work': f"Adjust keyword density ({data.get('density', 0):.1f}%) - optimal is 1-2.5%",
'acceptable': f"Keyword density ({data.get('density', 0):.1f}%) is acceptable - optimal is 1-2.5%",
},
'content_length': {
'no_content': 'Generate content first',
'too_short': f"Expand content from {data.get('words', 0)} words - aim for 1000+ words",
'acceptable': f"Content has {data.get('words', 0)} words - 1000+ is optimal for SEO",
'good': f"Good length ({data.get('words', 0)} words) - 1000+ is optimal",
},
'headers': {
'no_content': 'Generate content first',
'missing': 'Add H2 and H3 headers to structure your content',
'acceptable': 'Add H3 subheaders for better content structure',
},
'images': {
'no_images': 'Add images to improve engagement and SEO',
'missing_alt': f"Add alt text to your {data.get('count', 0)} image(s) for accessibility and SEO",
},
}
return advice_map.get(key, {}).get(status)
def _get_section_details(self, key, data):
"""Get additional details for display."""
parts = []
if key == 'title' and 'length' in data:
parts.append(f"<strong>Length:</strong> {data['length']} characters")
elif key == 'meta_desc' and 'length' in data:
parts.append(f"<strong>Length:</strong> {data['length']} characters")
elif key == 'meta_keywords' and 'count' in data:
parts.append(f"<strong>Count:</strong> {data['count']} keywords")
elif key == 'keywords' and 'density' in data:
parts.append(f"<strong>Density:</strong> {data['density']:.2f}%")
elif key == 'content_length' and 'words' in data:
parts.append(f"<strong>Word count:</strong> {data['words']}")
elif key == 'headers':
h2 = '✓' if data.get('h2') else '✗'
h3 = '✓' if data.get('h3') else '✗'
parts.append(f"<strong>H2:</strong> {h2} &nbsp; <strong>H3:</strong> {h3}")
elif key == 'images':
count = data.get('count', 0)
with_alt = data.get('with_alt', 0)
parts.append(f"<strong>Images:</strong> {count} ({with_alt} with alt text)")
return ' '.join(parts)
# === API Methods ===
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 > O'Toolkit."))
if not base_url:
raise UserError(_("O'Toolkit API endpoint is not configured."))
return base_url, api_key
@api.model
def _fetch_pricing_config(self, force=False):
"""Fetch pricing config from O'Toolkit API and cache in ir.config_parameter.
Cached for 1 hour. Returns dict with 'seo_content' and 'seo_content_image' keys,
each containing 'rate' and 'min_cost'.
"""
ICP = self.env['ir.config_parameter'].sudo()
cache_key = 'otoolkit_seo_content.pricing_cache'
cache_ts_key = 'otoolkit_seo_content.pricing_cache_ts'
# Check cache (1 hour TTL)
if not force:
cached = ICP.get_param(cache_key, '')
cache_ts = float(ICP.get_param(cache_ts_key, '0'))
if cached and (time.time() - cache_ts) < 3600:
try:
return json.loads(cached)
except (json.JSONDecodeError, ValueError):
pass
# Fetch from API
try:
base_url = ICP.get_param('otoolkit.api.endpoint', '')
if not base_url:
return {}
url = f"{base_url.rstrip('/')}/api/seo-content/pricing/"
response = requests.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
ICP.set_param(cache_key, json.dumps(data))
ICP.set_param(cache_ts_key, str(time.time()))
_logger.info("SEO pricing config refreshed from API: %s", data)
return data
except Exception as e:
_logger.warning("Failed to fetch pricing config: %s", e)
return {}
@api.model
def get_pricing_estimates(self):
"""Return estimated O'Token costs for content and images.
Uses pre-computed estimates from the API pricing endpoint,
so all pricing logic lives server-side and can be tuned
without updating the Odoo module.
"""
pricing = self._fetch_pricing_config()
estimates = pricing.get('estimates')
if estimates:
return {
'text': estimates.get('text', {}),
'image': estimates.get('image', 0),
}
# Fallback if API doesn't return estimates (old API version)
return {
'text': {'short': 200, 'medium': 440, 'long': 850},
'image': 250,
}
def _call_api(self, endpoint, payload, timeout=180):
"""Make API call to O'Toolkit backend."""
base_url, api_key = self._get_api_config()
headers = {
'Content-Type': 'application/json',
'Odoo-Api-Key': api_key,
}
url = f"{base_url.rstrip('/')}{endpoint}"
try:
response = requests.post(
url,
headers=headers,
json=payload,
timeout=timeout
)
_logger.info(f"API Response: {response.status_code} - {response.text[:500] if response.text else 'empty'}")
if response.status_code == 400:
try:
error_data = response.json()
except (ValueError, json.JSONDecodeError):
raise UserError(_("API Error (400): %s") % response.text[:500])
if error_data.get('type') == 'insufficient_funds':
raise UserError(_("Insufficient tokens. Please add more credits to your O'Toolkit account."))
# Handle both 'error' and 'detail' keys
error_msg = error_data.get('error') or error_data.get('detail') or 'Unknown error'
raise UserError(_("API Error: %s") % error_msg)
if response.status_code == 401:
raise UserError(_("Invalid API key. Please check your O'Toolkit configuration."))
if response.status_code >= 400:
raise UserError(_("API Error (%s): %s") % (response.status_code, response.text[:500]))
response.raise_for_status()
try:
return response.json()
except (ValueError, json.JSONDecodeError):
raise UserError(_("Invalid response from API: %s") % response.text[:200])
except requests.exceptions.Timeout:
raise UserError(_("Request timed out. Please try again."))
except requests.exceptions.ConnectionError:
raise UserError(_("Could not connect to O'Toolkit API. Please check your internet connection."))
except requests.exceptions.RequestException as e:
_logger.error(f"API Error: {e}")
raise UserError(_("API request failed: %s") % str(e))
# === Input Validation ===
def _validate_generation_inputs(self):
"""Validate inputs before sending to API to prevent abuse and errors."""
self.ensure_one()
errors = []
# Validate keywords length
if self.source_keywords and len(self.source_keywords) > MAX_KEYWORDS_LENGTH:
errors.append(_("Keywords are too long (max %d characters, got %d).") % (
MAX_KEYWORDS_LENGTH, len(self.source_keywords)))
# Validate topic length
if self.source_topic and len(self.source_topic) > MAX_TOPIC_LENGTH:
errors.append(_("Topic/brief is too long (max %d characters, got %d).") % (
MAX_TOPIC_LENGTH, len(self.source_topic)))
# Validate source text length
if self.source_text and len(self.source_text) > MAX_SOURCE_TEXT_LENGTH:
errors.append(_("Source text is too long (max %d characters, got %d).") % (
MAX_SOURCE_TEXT_LENGTH, len(self.source_text)))
# Validate required fields - accept any valid source input
has_valid_source = bool(
self.source_keywords or self.source_topic
or self.source_text or self.source_product_id
)
if not has_valid_source:
errors.append(_("Please provide keywords, a topic, existing content, or a product."))
# Validate requested image count
if self.requested_image_count < 0 or self.requested_image_count > 10:
errors.append(_("Image count must be between 0 and 10."))
# Validate references count
if len(self.reference_ids) > MAX_REFERENCES:
errors.append(_("Maximum %d references allowed (got %d).") % (
MAX_REFERENCES, len(self.reference_ids)))
if errors:
raise UserError('\n'.join(errors))
# === Actions ===
def action_generate(self):
"""Submit content generation request to API (async).
The content is generated asynchronously. A cron job polls for completion
and updates the record when ready. Images are generated AFTER content
is complete to enable content-aware prompts.
"""
self.ensure_one()
# Validate inputs before proceeding
self._validate_generation_inputs()
self.write({
'state': 'generating',
'error_message': False,
'api_task_id': False,
'generating_since': fields.Datetime.now(),
})
# Prepare payload
payload = {
'keywords': self.source_keywords or '',
'topic': self.source_topic or '',
'content_type': self.content_type,
'tone': self.tone,
'word_count': self.target_word_count,
'language': self.language_id.code if self.language_id else 'en_US',
'odoo_user_id': self.env.uid,
'options': {
'include_toc': self.template_id.include_toc if self.template_id else False,
'include_faq': self.template_id.include_faq if self.template_id else False,
'header_structure': self.template_id.header_structure if self.template_id else 'h2_h3',
}
}
# Add source text if available (optimize existing content)
if self.source_text:
payload['source_text'] = self.source_text
# Add product data if available
if self.source_product_id:
payload['product'] = {
'name': self.source_product_id.name,
'description': self.source_product_id.description or '',
'price': self.source_product_id.list_price,
'category': self.source_product_id.categ_id.complete_name if self.source_product_id.categ_id else '',
}
# Add template prompts if available
if self.template_id:
payload['template'] = {
'system_prompt': self.template_id.system_prompt,
'content_prompt': self.template_id.content_prompt_template,
}
# Add brand voice guidelines if available
if self.brand_voice_id:
payload['brand_voice'] = self.brand_voice_id.get_prompt_instructions()
# Add references if available
if self.reference_ids:
references_payload = []
for ref in self.reference_ids[:MAX_REFERENCES]:
ref_data = ref.get_api_payload()
if ref_data.get('content'): # Only include non-empty references
references_payload.append(ref_data)
if references_payload:
payload['references'] = references_payload
try:
result = self._call_api('/api/seo-content/generate/', payload)
if result.get('success') and result.get('task_id'):
# Store task ID for polling
self.write({
'api_task_id': str(result['task_id']),
})
_logger.info(f"Content generation task submitted for {self.id}: task_id={result['task_id']}")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Generation Started'),
'message': _('Content is being generated. You will be notified when ready.'),
'type': 'info',
'sticky': False,
}
}
else:
self.write({
'state': 'draft',
'error_message': result.get('error', 'Unknown error occurred'),
})
raise UserError(_("Content generation failed: %s") % result.get('error', 'Unknown error'))
except UserError:
raise
except Exception as e:
_logger.exception("Content generation error")
self.write({
'state': 'draft',
'error_message': str(e),
})
raise UserError(_("Content generation failed: %s") % str(e))
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': 'draft',
'error_message': _("Permanently failed after %d retries. Last error: %s") % (
self.retry_count, error_msg),
'generating_since': False,
})
_logger.warning("Content %s permanently failed after %d retries", self.id, self.retry_count)
# Send failure notification
if self.create_uid and self.create_uid.partner_id:
self.env['bus.bus']._sendone(
self.create_uid.partner_id,
'simple_notification',
{
'title': _("Generation Failed"),
'message': _("'%s' failed after %d retries.") % (self.name, self.retry_count),
'type': 'warning',
'sticky': True,
}
)
return
delay = backoff_minutes[min(self.retry_count, len(backoff_minutes) - 1)]
from datetime import timedelta
next_retry = fields.Datetime.now() + timedelta(minutes=delay)
self.write({
'state': 'draft',
'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),
'generating_since': False,
'api_task_id': False,
})
_logger.info("Content %s retry %d/%d scheduled at %s",
self.id, self.retry_count, self.max_retries, next_retry)
@api.model
def cron_poll_content_status(self):
"""Cron job to poll status of generating content.
Checks all content records in 'generating' state with an api_task_id,
polls the API for task status, and processes completed results.
Also handles stuck tasks and pending retries.
"""
from datetime import timedelta
now = fields.Datetime.now()
# --- Detect stuck tasks (generating for > 1 hour) ---
stuck_cutoff = now - timedelta(hours=1)
stuck_content = self.search([
('state', '=', 'generating'),
('generating_since', '!=', False),
('generating_since', '<', stuck_cutoff),
], limit=10)
for content in stuck_content:
_logger.warning("Content %s stuck in generating since %s, scheduling retry",
content.id, content.generating_since)
content._schedule_retry(_("Task stuck for over 1 hour"))
# --- Process pending retries ---
pending_retries = self.search([
('state', '=', 'draft'),
('next_retry_at', '!=', False),
('next_retry_at', '<=', now),
('retry_count', '<', 3),
], limit=5)
for content in pending_retries:
try:
_logger.info("Retrying content %s (attempt %d)", content.id, content.retry_count)
content.write({'next_retry_at': False})
content.action_generate()
except Exception as e:
_logger.error("Retry failed for content %s: %s", content.id, e)
content._schedule_retry(str(e))
# --- Check content stuck in images_pending with no active images ---
images_stuck_cutoff = now - timedelta(hours=2)
stuck_images_content = self.search([
('state', '=', 'images_pending'),
('write_date', '<', images_stuck_cutoff),
], limit=10)
for content in stuck_images_content:
pending_imgs = content.image_ids.filtered(
lambda i: i.state in ('pending', 'processing')
)
if not pending_imgs:
_logger.info("Content %s stuck in images_pending with no active images, moving to review", content.id)
content.write({'state': 'review'})
# --- Poll generating tasks ---
generating = self.search([
('state', '=', 'generating'),
('api_task_id', '!=', False),
], limit=20)
if not generating:
return
# Batch poll all tasks
task_ids = []
task_id_map = {} # Maps task_id to content record
for content in generating:
if content.api_task_id:
try:
task_id_int = int(content.api_task_id)
task_ids.append(task_id_int)
task_id_map[str(task_id_int)] = content
except (ValueError, TypeError):
_logger.warning(f"Invalid api_task_id for content {content.id}: {content.api_task_id}")
content.write({
'state': 'draft',
'error_message': f'Invalid task ID format: {content.api_task_id}',
})
if not task_ids:
return
try:
base_url, api_key = generating[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 from content tasks API: %s", response.text[:200])
return
tasks = data.get('tasks', []) if isinstance(data, dict) else data
task_result_map = {str(t.get('id')): t for t in tasks if t.get('id')}
for task_id_str, content 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', {})
content._process_completed_content(result)
elif status == 'failed':
error_msg = task.get('result', {}).get('error', 'Generation failed')
content._schedule_retry(error_msg)
except Exception as e:
_logger.error("Content status poll cron error: %s", e)
def _process_completed_content(self, result):
"""Process completed content generation result.
Updates content fields and triggers image generation if requested.
Images are generated AFTER content to enable content-aware prompts.
"""
self.ensure_one()
# Create version before updating
if self.generated_content:
self._create_version('Before regeneration')
# Build keywords string from API response
keywords_list = result.get('keywords', [])
keywords_str = ', '.join(keywords_list) if keywords_list else ''
self.write({
'generated_title': result.get('title', ''),
'generated_subtitle': result.get('subtitle', ''),
'generated_content': result.get('content', ''),
'generated_teaser': result.get('teaser', ''),
'website_meta_title': result.get('meta_title', ''),
'website_meta_description': result.get('meta_description', ''),
'generated_keywords': keywords_str,
'website_meta_keywords': keywords_str,
'token_cost': result.get('cost', 0),
'api_task_id': False, # Clear task ID
'state': 'review' if self.requested_image_count == 0 else 'images_pending',
})
# Reset retry fields on success
self.write({
'retry_count': 0,
'next_retry_at': False,
'generating_since': False,
})
_logger.info(f"Content {self.id} generation completed: {self.generated_title}")
# Send bus notification to content creator
if self.create_uid and self.create_uid.partner_id:
self.env['bus.bus']._sendone(
self.create_uid.partner_id,
'simple_notification',
{
'title': _("Content Ready!"),
'message': _("'%s' is ready for review.") % (self.generated_title or self.name),
'type': 'success',
'sticky': False,
}
)
# Generate images AFTER content is ready (content-aware prompts)
if self.requested_image_count > 0:
self._generate_images()
def _generate_images(self):
"""Create image generation requests."""
self.ensure_one()
# Generate prompts based on content
prompts = self._generate_image_prompts()
# Use content's image_style, fallback to template default, then 'photorealistic'
image_style = self.image_style or (
self.template_id.default_image_style if self.template_id else 'photorealistic'
)
for i, prompt in enumerate(prompts[:self.requested_image_count]):
self.env['otk.seo.content.image'].create({
'content_id': self.id,
'name': f"Image {i + 1}",
'prompt': prompt,
'style': image_style,
'aspect_ratio': '1792x1024' if i == 0 else '1024x1024', # First image landscape for cover
'is_cover': i == 0,
'state': 'pending',
})
def _generate_image_prompts(self):
"""Generate image prompts based on content context."""
prompts = []
title = self.generated_title or self.name
keywords = self.source_keywords or ''
# Extract rich context from generated content
content_context = self._extract_content_context()
# Cover image - main theme with full context
cover_prompt = self._build_cover_prompt(title, keywords, content_context)
prompts.append(cover_prompt)
# Generate section-specific prompts for additional images
if self.generated_content and self.requested_image_count > 1:
section_prompts = self._generate_section_prompts(title, content_context)
prompts.extend(section_prompts)
return prompts
def _extract_content_context(self):
"""Extract key context from generated content for image prompts."""
if not self.generated_content:
return {
'sections': [],
'summary': '',
'word_count': 0,
'content_type': self.content_type,
'tone': self.tone,
}
content_text = html2plaintext(self.generated_content)
# Extract H2 headers (main topic sections)
h2_pattern = r'<h2[^>]*>(.*?)</h2>'
h2_matches = re.findall(h2_pattern, self.generated_content, re.IGNORECASE | re.DOTALL)
sections = [html2plaintext(h2).strip() for h2 in h2_matches if h2.strip()]
# Get first paragraph as summary
first_para = ''
para_match = re.search(r'<p[^>]*>(.*?)</p>', self.generated_content, re.IGNORECASE | re.DOTALL)
if para_match:
first_para = html2plaintext(para_match.group(1)).strip()[:200]
# Extract any key points from lists
list_items = []
li_pattern = r'<li[^>]*>(.*?)</li>'
li_matches = re.findall(li_pattern, self.generated_content, re.IGNORECASE | re.DOTALL)
for li in li_matches[:5]: # First 5 list items
item_text = html2plaintext(li).strip()
if item_text and len(item_text) < 100:
list_items.append(item_text)
return {
'sections': sections[:5], # Top 5 sections
'summary': first_para,
'key_points': list_items,
'word_count': len(content_text.split()),
'content_type': self.content_type,
'tone': self.tone,
}
def _build_cover_prompt(self, title, keywords, context):
"""Build a rich, context-aware cover image prompt."""
prompt_parts = []
# Tone-based styling prefix
tone_styles = {
'professional': 'Professional, polished',
'casual': 'Friendly, approachable',
'technical': 'Technical, precise',
'persuasive': 'Bold, compelling',
'conversational': 'Warm, engaging',
}
tone_style = tone_styles.get(context.get('tone', 'professional'), 'Professional')
# Start with tone and title
prompt_parts.append(f"{tone_style} hero image for: {title}.")
# Add summary context if available
if context.get('summary'):
# Truncate summary for prompt
summary = context['summary'][:150]
prompt_parts.append(f"Visualizing: {summary}.")
# Add keywords as themes
if keywords:
prompt_parts.append(f"Key themes: {keywords}.")
# Content type specific styling
type_styles = {
'blog_post': "Modern blog header style, engaging and shareable, wide cinematic composition.",
'product_desc': "Product-focused imagery, clean e-commerce aesthetic, professional lighting.",
'category_desc': "Category showcase, diverse visual elements representing the category.",
'landing_page': "Hero banner style, impactful and conversion-focused, bold composition.",
'social_post': "Social media optimized, eye-catching, vibrant colors.",
}
style_desc = type_styles.get(context.get('content_type', 'blog_post'),
"Clean, modern web imagery.")
prompt_parts.append(style_desc)
# Quality and style modifiers
prompt_parts.append("High quality, professionally composed, no text overlays or watermarks.")
return ' '.join(prompt_parts)
def _generate_section_prompts(self, main_title, context):
"""Generate prompts for section-specific supporting images."""
prompts = []
sections = context.get('sections', [])
key_points = context.get('key_points', [])
tone = context.get('tone', 'professional')
# Generate prompts from H2 sections first
for i, section in enumerate(sections[:self.requested_image_count - 1]):
prompt_parts = [
f"Supporting illustration for: '{section}'.",
f"Related to main topic: {main_title}.",
]
# Add visual style variation based on position
if i == 0:
prompt_parts.append("Conceptual illustration style, clear visual metaphor.")
elif i == 1:
prompt_parts.append("Informative diagram style, showing process or relationship.")
else:
prompt_parts.append("Supporting visual, complementary to main theme.")
prompt_parts.append("Clean composition, web-friendly, no text.")
prompts.append(' '.join(prompt_parts))
# If we need more prompts, use key points
remaining_count = (self.requested_image_count - 1) - len(prompts)
if remaining_count > 0 and key_points:
for point in key_points[:remaining_count]:
prompt = (
f"Visual representation of: '{point}'. "
f"Supporting image for {main_title}. "
f"Clean, focused illustration, no text overlay."
)
prompts.append(prompt)
# Fallback generic prompts if still need more
remaining_count = (self.requested_image_count - 1) - len(prompts)
for i in range(remaining_count):
fallback_styles = [
"Infographic-style supporting visual",
"Abstract conceptual illustration",
"Detail-focused supporting image",
]
style = fallback_styles[i % len(fallback_styles)]
prompt = (
f"{style} for: {main_title}. "
f"Professional quality, web-optimized, no text."
)
prompts.append(prompt)
return prompts
# === Image Integration ===
def _insert_images_into_content(self):
"""Insert generated images into content body.
First checks for placeholder format, then falls back to section-based insertion.
"""
self.ensure_one()
if not self.generated_content:
return False
# Check for placeholder format first
if 'otk-image-placeholder' in self.generated_content:
return self._replace_image_placeholders()
# Fallback: Insert at section breaks
return self._insert_images_at_sections()
def _replace_image_placeholders(self):
"""Replace image placeholders with actual generated images."""
self.ensure_one()
# Convert to string to work with regex (Html field might be Markup)
content = str(self.generated_content or '')
done_images = self.image_ids.filtered(
lambda i: i.state == 'done' and i.image and not i.is_cover
).sorted('sequence')
if not done_images:
return False
# Find and replace placeholders
placeholder_pattern = r'<figure[^>]*class="[^"]*otk-image-placeholder[^"]*"[^>]*data-position="(\d+)"[^>]*>.*?</figure>'
def replace_placeholder(match):
position = int(match.group(1))
image_index = position - 1 # 0-indexed
if image_index < len(done_images):
img = done_images[image_index]
return self._build_image_html(img)
return '' # Remove placeholder if no image
new_content = re.sub(placeholder_pattern, replace_placeholder, content, flags=re.DOTALL)
if new_content != content:
# Mark images as inserted
done_images.write({'inserted_in_content': True})
# Use Markup to prevent HTML escaping
self.generated_content = Markup(new_content)
return True
return False
def _insert_images_at_sections(self):
"""Insert images at section breaks (after H2 headers) when no placeholders exist."""
self.ensure_one()
# Convert to string to work with regex (Html field might be Markup)
content = str(self.generated_content or '')
done_images = self.image_ids.filtered(
lambda i: i.state == 'done' and i.image and not i.is_cover and not i.inserted_in_content
).sorted('sequence')
if not done_images:
return False
# Find H2 closing tags to insert images after
h2_pattern = r'(</h2>)'
matches = list(re.finditer(h2_pattern, content, re.IGNORECASE))
if not matches:
# No H2s found, try inserting after first paragraph
p_match = re.search(r'(</p>)', content, re.IGNORECASE)
if p_match and done_images:
img = done_images[0]
img_html = self._build_image_html(img)
insert_pos = p_match.end()
content = content[:insert_pos] + img_html + content[insert_pos:]
img.inserted_in_content = True
# Use Markup to prevent HTML escaping
self.generated_content = Markup(content)
return True
return False
# Insert images after every 2nd H2 (spread them out)
# For 1 image: after 2nd H2
# For 2 images: after 2nd and 4th H2
# etc.
insert_points = matches[1::2] # Every other H2, starting from 2nd (index 1)
if not insert_points:
insert_points = matches[:1] # Use first H2 if only one exists
offset = 0
inserted_count = 0
for i, match in enumerate(insert_points):
if i >= len(done_images):
break
img = done_images[i]
img_html = self._build_image_html(img)
insert_pos = match.end() + offset
content = content[:insert_pos] + img_html + content[insert_pos:]
offset += len(img_html)
img.inserted_in_content = True
inserted_count += 1
if inserted_count > 0:
# Use Markup to prevent HTML escaping
self.generated_content = Markup(content)
return True
return False
def _build_image_html(self, image):
"""Build HTML for inserting an image into content."""
alt_text = image.alt_text or ''
# Escape HTML special characters in alt text
alt_text_escaped = alt_text.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
html = f'''
<figure class="otk-generated-image text-center my-4">
<img src="/web/image/otk.seo.content.image/{image.id}/image"
alt="{alt_text_escaped}"
class="img-fluid rounded shadow-sm"
loading="lazy"
style="max-width: 100%; height: auto;"/>'''
if alt_text:
html += f'''
<figcaption class="text-muted small mt-2 fst-italic">{alt_text_escaped}</figcaption>'''
html += '''
</figure>
'''
return html
def action_insert_images(self):
"""Manually insert/refresh images in content."""
self.ensure_one()
done_images = self.image_ids.filtered(lambda i: i.state == 'done' and i.image)
if not done_images:
raise UserError(_("No completed images to insert. Please generate images first."))
# Reset inserted flag to allow re-insertion
done_images.filtered(lambda i: not i.is_cover).write({'inserted_in_content': False})
inserted = self._insert_images_into_content()
if inserted:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Images Inserted'),
'message': _('Images have been inserted into the content.'),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
}
}
else:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('No Changes'),
'message': _('No suitable insertion points found in content.'),
'type': 'warning',
'sticky': False,
}
}
def action_regenerate(self):
"""Regenerate content."""
self.ensure_one()
return self.action_generate()
def action_approve(self):
"""Approve content for publishing."""
self.ensure_one()
self.write({'state': 'approved'})
def action_back_to_draft(self):
"""Return to draft state for editing."""
self.ensure_one()
self.write({'state': 'draft'})
def action_create_blog_post(self):
"""Create a blog.post from generated content."""
self.ensure_one()
# Check if website_blog is installed
if not self.env['ir.module.module'].search([
('name', '=', 'website_blog'),
('state', '=', 'installed')
]):
raise UserError(_("Website Blog module is not installed. Please install it first."))
if not self.blog_id:
# Get default blog
self.blog_id = self.env['blog.blog'].search([], limit=1)
if not self.blog_id:
raise UserError(_("No blog found. Please create a blog first or select one."))
# Insert non-cover images into content before creating blog post
non_cover_images = self.image_ids.filtered(
lambda i: i.state == 'done' and i.image and not i.is_cover and not i.inserted_in_content
)
if non_cover_images:
self._insert_images_into_content()
# Prepare cover image
cover_properties = {}
cover_image = self.image_ids.filtered(lambda i: i.is_cover and i.state == 'done')[:1]
if cover_image:
cover_properties = {
'background-image': f"url('/web/image/otk.seo.content.image/{cover_image.id}/image')",
'resize_class': 'o_half_screen_height',
'opacity': '0.4',
'background_color_class': 'o_cc3',
}
# Create blog post
blog_post = self.env['blog.post'].create({
'name': self.generated_title or self.name,
'subtitle': self.generated_subtitle,
'content': self.generated_content,
'teaser_manual': self.generated_teaser,
'website_meta_title': self.website_meta_title,
'website_meta_description': self.website_meta_description,
'website_meta_keywords': self.website_meta_keywords,
'cover_properties': json.dumps(cover_properties) if cover_properties else False,
'author_id': self.env.user.partner_id.id,
'blog_id': self.blog_id.id,
'is_published': False,
})
self.write({
'blog_post_id': blog_post.id,
'state': 'published',
})
return {
'type': 'ir.actions.act_window',
'res_model': 'blog.post',
'res_id': blog_post.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_blog_post(self):
"""Open the created blog post."""
self.ensure_one()
if not self.blog_post_id:
raise UserError(_("No blog post has been created yet."))
return {
'type': 'ir.actions.act_window',
'res_model': 'blog.post',
'res_id': self.blog_post_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_images(self):
"""View generated images."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'otk.seo.content.image',
'view_mode': 'list,form',
'domain': [('content_id', '=', self.id)],
'context': {'default_content_id': self.id},
'name': _('Generated Images'),
}
def action_apply_to_product(self):
"""Apply generated SEO content to the source product."""
self.ensure_one()
if not self.source_product_id:
raise UserError(_("No source product is linked to this content."))
if self.content_type != 'product_desc':
raise UserError(_("This action is only available for Product Description content."))
# Prepare values to update
vals = {}
if self.website_meta_title:
vals['website_meta_title'] = self.website_meta_title
if self.website_meta_description:
vals['website_meta_description'] = self.website_meta_description
if self.website_meta_keywords:
vals['website_meta_keywords'] = self.website_meta_keywords
if self.generated_content:
vals['website_description'] = self.generated_content
if not vals:
raise UserError(_("No SEO content to apply. Please generate content first."))
# Apply to product
self.source_product_id.write(vals)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('SEO Applied'),
'message': _('SEO content has been applied to product "%s".') % self.source_product_id.name,
'type': 'success',
'sticky': False,
}
}
# === Dashboard ===
@api.model
def get_dashboard_data(self):
"""Return aggregated dashboard data for the SEO content dashboard."""
Content = self.env['otk.seo.content']
# Total content
total_content = Content.search_count([])
# Average SEO score (using read_group)
avg_data = Content._read_group(
[('seo_score', '>', 0)],
[],
['seo_score:avg'],
)
avg_seo_score = round(avg_data[0][0], 1) if avg_data else 0
# Total tokens
token_data = Content._read_group(
[],
[],
['token_cost:sum'],
)
total_tokens = token_data[0][0] or 0 if token_data else 0
# Stale content count
stale_count = Content.search_count([('is_stale', '=', True)])
# Content by state
state_data = Content._read_group(
[],
['state'],
['__count'],
)
content_by_state = {state: count for state, count in state_data}
# Content by type
type_data = Content._read_group(
[],
['content_type'],
['__count'],
)
content_by_type = {ctype: count for ctype, count in type_data}
# Recent content (last 10)
recent = Content.search_read(
[],
['name', 'content_type', 'state', 'seo_score', 'create_date'],
limit=10,
order='create_date desc',
)
return {
'totalContent': total_content,
'avgSeoScore': avg_seo_score,
'totalTokens': total_tokens,
'staleCount': stale_count,
'contentByState': content_by_state,
'contentByType': content_by_type,
'recentContent': recent,
}
# === Version Management ===
def _create_version(self, note=None):
"""Create a version snapshot before changes."""
self.ensure_one()
# Limit versions (configurable via settings)
ICP = self.env['ir.config_parameter'].sudo()
max_versions = int(ICP.get_param('otoolkit_seo_content.max_versions', '5'))
if len(self.version_ids) >= max_versions:
oldest = self.version_ids.sorted('version_number')[0]
oldest.unlink()
self.env['otk.seo.content.version'].create({
'content_id': self.id,
'version_number': self.current_version,
'generated_title': self.generated_title,
'generated_subtitle': self.generated_subtitle,
'generated_content': self.generated_content,
'generated_teaser': self.generated_teaser,
'website_meta_title': self.website_meta_title,
'website_meta_description': self.website_meta_description,
'note': note or f'Version {self.current_version}',
})
self.current_version += 1
def action_restore_version(self):
"""Open wizard to select version to restore."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'otk.seo.content.version',
'view_mode': 'list',
'domain': [('content_id', '=', self.id)],
'name': _('Version History'),
'target': 'new',
}
# === Content Freshness ===
def action_mark_refreshed(self):
"""Mark content as freshly reviewed/updated."""
self.ensure_one()
self.write({
'last_refreshed_date': fields.Date.today(),
'refresh_alert_sent': False,
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Content Refreshed'),
'message': _('Content has been marked as refreshed.'),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
}
}
@api.model
def cron_check_stale_content(self):
"""Cron job to check for stale content and send alerts."""
# Find published/approved content that is stale and hasn't had alert sent
stale_content = self.search([
('state', 'in', ['published', 'approved']),
('is_stale', '=', True),
('refresh_alert_sent', '=', False),
])
for content in stale_content:
# Create activity for content creator
content.activity_schedule(
'mail.mail_activity_data_todo',
summary=_('Content needs refresh: %s') % content.name,
note=_('This content was last refreshed %d days ago and may need updating to maintain SEO rankings.') % content.days_since_refresh,
user_id=content.create_uid.id,
)
content.refresh_alert_sent = True
_logger.info(f"Refresh alert sent for content {content.id}: {content.name}")
return True
@api.model
def action_view_stale_content(self):
"""View all stale content that needs refresh."""
return {
'type': 'ir.actions.act_window',
'name': _('Content Needing Refresh'),
'res_model': 'otk.seo.content',
'view_mode': 'list,form',
'domain': [('is_stale', '=', True)],
'context': {'search_default_filter_stale': True},
}
class OtkSeoContentVersion(models.Model):
_name = 'otk.seo.content.version'
_description = 'SEO Content Version History'
_order = 'version_number desc'
content_id = fields.Many2one('otk.seo.content', required=True, ondelete='cascade',
help='The parent content record this version belongs to.')
version_number = fields.Integer('Version', required=True,
help='Sequential version number for this snapshot.')
# Snapshot of content at this version
generated_title = fields.Char('Title',
help='The title at the time this version was saved.')
generated_subtitle = fields.Char('Subtitle',
help='The subtitle at the time this version was saved.')
generated_content = fields.Html('HTML Content', sanitize=False,
help='The full HTML content at the time this version was saved.')
generated_teaser = fields.Text('Teaser',
help='The teaser text at the time this version was saved.')
website_meta_title = fields.Char('Meta Title',
help='The SEO meta title at the time this version was saved.')
website_meta_description = fields.Text('Meta Description',
help='The SEO meta description at the time this version was saved.')
# Metadata
created_by = fields.Many2one('res.users', default=lambda self: self.env.user,
help='The user who created this version snapshot.')
created_at = fields.Datetime(default=fields.Datetime.now,
help='When this version was saved.')
note = fields.Char('Version Note',
help='A brief description of why this version was created (e.g., "Before regeneration", "Manual edit").')
# Diff preview
diff_preview = fields.Html('Diff Preview', compute='_compute_diff_preview', sanitize=False,
help='Shows differences between this version and the current content.')
@api.depends('generated_content', 'content_id.generated_content')
def _compute_diff_preview(self):
for record in self:
if not record.generated_content or not record.content_id.generated_content:
record.diff_preview = False
continue
old_text = html2plaintext(record.generated_content).splitlines()
new_text = html2plaintext(record.content_id.generated_content).splitlines()
diff = difflib.unified_diff(
old_text, new_text,
fromfile=_('Version %s') % record.version_number,
tofile=_('Current'),
lineterm='',
)
diff_lines = []
for line in diff:
if line.startswith('+++') or line.startswith('---'):
diff_lines.append(f'<div class="o_diff_header">{line}</div>')
elif line.startswith('@@'):
diff_lines.append(f'<div class="o_diff_section">{line}</div>')
elif line.startswith('+'):
diff_lines.append(f'<div class="o_diff_add">{line}</div>')
elif line.startswith('-'):
diff_lines.append(f'<div class="o_diff_remove">{line}</div>')
else:
diff_lines.append(f'<div class="o_diff_context">{line}</div>')
if diff_lines:
record.diff_preview = Markup(
'<div class="o_seo_diff_view">' + ''.join(diff_lines) + '</div>'
)
else:
record.diff_preview = Markup(
'<div class="text-muted text-center py-3">'
'<i class="fa fa-check-circle text-success"/> No differences</div>'
)
def action_view_diff(self):
"""Open a dialog showing the diff between this version and current content."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Version %s - Diff') % self.version_number,
'res_model': 'otk.seo.content.version',
'res_id': self.id,
'view_mode': 'form',
'view_id': self.env.ref('otoolkit_seo_content.otk_seo_content_version_view_diff').id,
'target': 'new',
}
def action_restore(self):
"""Restore this version to the content."""
self.ensure_one()
# Create version of current state first
self.content_id._create_version(note='Before restore')
# Restore
self.content_id.write({
'generated_title': self.generated_title,
'generated_subtitle': self.generated_subtitle,
'generated_content': self.generated_content,
'generated_teaser': self.generated_teaser,
'website_meta_title': self.website_meta_title,
'website_meta_description': self.website_meta_description,
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Version Restored'),
'message': _('Content has been restored to version %s.') % self.version_number,
'type': 'success',
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
}
}