This commit is contained in:
2026-07-11 01:28:32 +02:00
parent 0a6176cf53
commit 8e01c7c8ec
99 changed files with 34739 additions and 0 deletions
@@ -0,0 +1,8 @@
from . import brand_voice
from . import seo_content
from . import seo_content_batch
from . import seo_content_idea
from . import seo_content_image
from . import seo_reference
from . import seo_template
from . import res_config_settings
@@ -0,0 +1,189 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class OtkSeoBrandVoice(models.Model):
_name = 'otk.seo.brand.voice'
_description = 'Brand Voice Guidelines'
_order = 'sequence, name'
name = fields.Char('Brand Name', required=True, translate=True,
help='Name of the brand or voice profile (e.g., "Company Main Brand", "Product Line X")')
sequence = fields.Integer('Sequence', default=10)
active = fields.Boolean('Active', default=True)
is_default = fields.Boolean('Default Voice',
help='If enabled, this voice will be automatically selected for new content.')
# === Brand Identity ===
brand_description = fields.Text('Brand Description', translate=True,
help='Brief description of the brand, its values, and target audience. '
'Example: "We are a modern tech company targeting young professionals..."')
target_audience = fields.Text('Target Audience', translate=True,
help='Describe the ideal reader/customer. '
'Example: "Small business owners aged 30-50, tech-savvy but time-constrained..."')
# === Voice Characteristics ===
voice_tone = fields.Selection([
('formal', 'Formal & Professional'),
('friendly', 'Friendly & Approachable'),
('authoritative', 'Authoritative & Expert'),
('conversational', 'Conversational & Casual'),
('inspirational', 'Inspirational & Motivating'),
('educational', 'Educational & Informative'),
('playful', 'Playful & Fun'),
('empathetic', 'Empathetic & Supportive'),
], string='Voice Tone', default='friendly',
help='The overall tone of voice to use in content.')
voice_personality = fields.Char('Personality Traits',
help='Comma-separated personality traits. '
'Example: "innovative, trustworthy, approachable, knowledgeable"')
writing_style = fields.Selection([
('concise', 'Concise & Direct'),
('detailed', 'Detailed & Thorough'),
('storytelling', 'Storytelling & Narrative'),
('factual', 'Factual & Data-driven'),
('persuasive', 'Persuasive & Sales-oriented'),
('conversational', 'Conversational & Casual'),
], string='Writing Style', default='concise',
help='The preferred writing style for content.')
# === Vocabulary Guidelines ===
preferred_words = fields.Text('Preferred Words & Phrases', translate=True,
help='Words and phrases to use frequently. One per line. '
'Example:\ninnovative\ncutting-edge\nseamless experience')
avoided_words = fields.Text('Words to Avoid', translate=True,
help='Words and phrases to never use. One per line. '
'Example:\ncheap\nbasic\njust')
industry_terms = fields.Text('Industry Terminology', translate=True,
help='Industry-specific terms and their preferred usage. '
'Example:\nSaaS (not "software as a service")\nAI-powered (not "artificial intelligence powered")')
# === Custom Instructions ===
custom_instructions = fields.Text('Custom AI Instructions', translate=True,
help='Additional instructions for the AI when generating content. '
'Example: "Always include a call-to-action at the end. '
'Reference sustainability when relevant. Never make claims without evidence."')
# === Examples ===
example_good = fields.Text('Example of Good Content',
help='Paste an example of content that represents your brand voice well.')
example_bad = fields.Text('Example of What to Avoid',
help='Paste an example of content that does NOT match your brand voice.')
# === Usage Tracking ===
content_count = fields.Integer('Content Count', compute='_compute_content_count',
help='Number of content pieces using this brand voice.')
@api.depends('name')
def _compute_content_count(self):
if not self.ids:
return
data = self.env['otk.seo.content']._read_group(
[('brand_voice_id', 'in', self.ids)],
['brand_voice_id'],
['__count'],
)
counts = {voice.id: count for voice, count in data}
for record in self:
record.content_count = counts.get(record.id, 0)
@api.constrains('is_default')
def _check_single_default(self):
"""Ensure only one brand voice is set as default."""
for record in self:
if record.is_default:
existing_default = self.search([
('is_default', '=', True),
('id', '!=', record.id),
('active', '=', True)
])
if existing_default:
existing_default.write({'is_default': False})
@api.model
def get_default_voice(self):
"""Get the default brand voice, if any."""
return self.search([('is_default', '=', True), ('active', '=', True)], limit=1)
def get_prompt_instructions(self):
"""Generate AI instructions from brand voice settings."""
self.ensure_one()
instructions = []
# Brand identity
if self.brand_description:
instructions.append(f"Brand Context: {self.brand_description}")
if self.target_audience:
instructions.append(f"Target Audience: {self.target_audience}")
# Voice characteristics
tone_descriptions = {
'formal': 'Use a formal, professional tone. Avoid slang and casual expressions.',
'friendly': 'Use a friendly, approachable tone. Be warm but professional.',
'authoritative': 'Write with authority and expertise. Be confident and assertive.',
'conversational': 'Write in a conversational, casual tone. Use contractions and relatable language.',
'inspirational': 'Write in an inspirational, motivating tone. Use uplifting language.',
'educational': 'Write in an educational, informative tone. Explain concepts clearly.',
'playful': 'Write in a playful, fun tone. Use humor where appropriate.',
'empathetic': 'Write with empathy and understanding. Acknowledge reader challenges.',
}
if self.voice_tone:
instructions.append(f"Voice Tone: {tone_descriptions.get(self.voice_tone, self.voice_tone)}")
if self.voice_personality:
instructions.append(f"Personality Traits: The content should feel {self.voice_personality}.")
style_descriptions = {
'concise': 'Keep writing concise and direct. Avoid unnecessary words.',
'detailed': 'Provide detailed, thorough explanations. Be comprehensive.',
'storytelling': 'Use storytelling and narrative techniques. Engage emotionally.',
'factual': 'Focus on facts and data. Support claims with evidence.',
'persuasive': 'Write persuasively. Focus on benefits and calls-to-action.',
}
if self.writing_style:
instructions.append(f"Writing Style: {style_descriptions.get(self.writing_style, self.writing_style)}")
# Vocabulary
if self.preferred_words:
words = [w.strip() for w in self.preferred_words.split('\n') if w.strip()]
if words:
instructions.append(f"Preferred vocabulary (use these when appropriate): {', '.join(words[:15])}")
if self.avoided_words:
words = [w.strip() for w in self.avoided_words.split('\n') if w.strip()]
if words:
instructions.append(f"Words to AVOID: {', '.join(words[:15])}")
if self.industry_terms:
instructions.append(f"Industry terminology: {self.industry_terms}")
# Custom instructions
if self.custom_instructions:
instructions.append(f"Additional guidelines: {self.custom_instructions}")
# Examples
if self.example_good:
instructions.append(f"Example of desired content style:\n{self.example_good[:500]}")
return '\n\n'.join(instructions)
def action_view_content(self):
"""View content using this brand voice."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Content with %s Voice') % self.name,
'res_model': 'otk.seo.content',
'view_mode': 'list,form',
'domain': [('brand_voice_id', '=', self.id)],
'context': {'default_brand_voice_id': self.id},
}
@@ -0,0 +1,43 @@
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# === SEO Content Settings ===
seo_default_blog_id = fields.Many2one(
'blog.blog',
string='Default Blog',
config_parameter='otoolkit_seo_content.default_blog_id',
help='The default blog where generated content will be published. Users can override this for individual content items.'
)
seo_default_image_count = fields.Integer(
string='Default Image Count',
config_parameter='otoolkit_seo_content.default_image_count',
default=1,
help='Default number of AI-generated images to create per content item. Set to 0 to disable image generation by default.'
)
seo_default_image_quality = fields.Selection([
('standard', 'Standard'),
('hd', 'HD'),
], string='Default Image Quality',
config_parameter='otoolkit_seo_content.default_image_quality',
default='standard',
help='Default quality setting for generated images. HD produces sharper images with more detail but consumes more tokens.'
)
seo_auto_generate_alt_text = fields.Boolean(
string='Auto-generate Alt Text',
config_parameter='otoolkit_seo_content.auto_generate_alt_text',
default=True,
help='When enabled, SEO-friendly alt text will be automatically generated for each image based on the content title and context.'
)
seo_max_versions = fields.Integer(
string='Max Version History',
config_parameter='otoolkit_seo_content.max_versions',
default=5,
help='Maximum number of content versions to keep in history. When exceeded, the oldest version is automatically deleted. Set to 0 to disable version history.'
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,535 @@
import base64
import csv
import io
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class SeoContentBatch(models.Model):
_name = 'otk.seo.content.batch'
_description = 'SEO Content Batch Job'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
name = fields.Char('Batch Name', required=True, default=lambda self: _('New Batch'))
state = fields.Selection([
('draft', 'Draft'),
('queued', 'Queued'),
('processing', 'Processing'),
('paused', 'Paused'),
('done', 'Completed'),
('failed', 'Failed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True)
# === Concurrency Settings ===
concurrency = fields.Integer('Concurrency', default=5,
help='Number of items to process per cron run. Higher values process faster but use more resources.')
@api.constrains('concurrency')
def _check_concurrency_range(self):
for record in self:
if not (1 <= record.concurrency <= 10):
raise ValidationError(_('Concurrency must be between 1 and 10.'))
# === Source Configuration ===
source_type = fields.Selection([
('products', 'From Products'),
('csv', 'From CSV'),
('keywords', 'From Keywords List'),
], string='Source Type', default='products', required=True)
product_ids = fields.Many2many('product.template', string='Products',
help='Products to generate content for')
csv_file = fields.Binary('CSV File', attachment=True)
csv_filename = fields.Char('CSV Filename')
keywords_list = fields.Text('Keywords List',
help='One keyword/topic per line')
# === Batch Items ===
item_ids = fields.One2many('otk.seo.content.batch.item', 'batch_id', string='Batch Items')
# === Shared Settings ===
content_type = fields.Selection([
('blog_post', 'Blog Post'),
('product_desc', 'Product Description'),
('category_desc', 'Category Description'),
('landing_page', 'Landing Page Content'),
], string='Content Type', default='product_desc', required=True)
template_id = fields.Many2one('otk.seo.template', 'Content Template',
domain="[('content_type', '=', content_type)]")
brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
default=lambda self: self.env['otk.seo.brand.voice'].search([('is_default', '=', True)], limit=1))
tone = fields.Selection([
('professional', 'Professional'),
('casual', 'Casual'),
('technical', 'Technical'),
('persuasive', 'Persuasive'),
('conversational', 'Conversational'),
], string='Tone', default='professional')
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')
language_id = fields.Many2one('res.lang', 'Language',
default=lambda self: self.env['res.lang']._lang_get(self.env.lang or 'en_US'))
# === Image Settings ===
include_images = fields.Boolean('Generate Images', default=True)
image_count = fields.Integer('Images per Content', default=1)
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')
# === Blog Settings ===
blog_id = fields.Many2one('blog.blog', 'Target Blog',
help='The blog where posts will be published. Only used for Blog Post content type.')
# === Progress Tracking ===
total_items = fields.Integer('Total Items', compute='_compute_progress', store=True)
completed_items = fields.Integer('Completed', compute='_compute_progress', store=True)
failed_items = fields.Integer('Failed', compute='_compute_progress', store=True)
progress_percent = fields.Float('Progress %', compute='_compute_progress', store=True)
# === Generated Content ===
content_ids = fields.One2many('otk.seo.content', 'batch_id', string='Generated Content')
content_count = fields.Integer('Content Count', compute='_compute_content_count')
# === Timing ===
started_at = fields.Datetime('Started At')
completed_at = fields.Datetime('Completed At')
duration = fields.Float('Duration (minutes)', compute='_compute_duration')
@api.depends('item_ids', 'item_ids.state')
def _compute_progress(self):
for batch in self:
items = batch.item_ids
batch.total_items = len(items)
batch.completed_items = len(items.filtered(lambda i: i.state == 'done'))
batch.failed_items = len(items.filtered(lambda i: i.state == 'failed'))
batch.progress_percent = (
(batch.completed_items + batch.failed_items) / batch.total_items * 100
if batch.total_items else 0
)
@api.depends('content_ids')
def _compute_content_count(self):
for batch in self:
batch.content_count = len(batch.content_ids)
# === ETA ===
eta_minutes = fields.Float('ETA (minutes)', compute='_compute_eta',
help='Estimated time remaining for batch completion.')
avg_processing_time = fields.Float('Avg Time (s)', compute='_compute_eta',
help='Average processing time per item in seconds.')
@api.depends('started_at', 'completed_at')
def _compute_duration(self):
for batch in self:
if batch.started_at and batch.completed_at:
delta = batch.completed_at - batch.started_at
batch.duration = delta.total_seconds() / 60
else:
batch.duration = 0
@api.depends('started_at', 'completed_items', 'failed_items', 'total_items')
def _compute_eta(self):
for batch in self:
done_count = batch.completed_items + batch.failed_items
if batch.started_at and done_count > 0:
now = fields.Datetime.now()
elapsed = (now - batch.started_at).total_seconds()
avg_time = elapsed / done_count
remaining = batch.total_items - done_count
batch.avg_processing_time = round(avg_time, 1)
batch.eta_minutes = round((avg_time * remaining) / 60, 1)
else:
batch.avg_processing_time = 0
batch.eta_minutes = 0
@api.onchange('source_type')
def _onchange_source_type(self):
"""Update content type based on source selection."""
if self.source_type == 'products':
self.content_type = 'product_desc'
elif self.source_type == 'keywords':
# Keywords can be for any content type, default to blog
if self.content_type == 'product_desc':
self.content_type = 'blog_post'
@api.onchange('content_type')
def _onchange_content_type(self):
"""Reset template when content type changes and update source type."""
self.template_id = False
# If switching to product_desc, suggest products source
if self.content_type == 'product_desc' and self.source_type == 'keywords':
self.source_type = 'products'
# If switching away from product_desc with products source, switch to keywords
elif self.content_type != 'product_desc' and self.source_type == 'products':
self.source_type = 'keywords'
@api.onchange('template_id')
def _onchange_template_id(self):
"""Apply template settings to batch."""
if self.template_id:
self.tone = self.template_id.default_tone
self.target_word_count = self.template_id.default_word_count
self.include_images = self.template_id.include_images
self.image_count = self.template_id.default_image_count
self.image_style = self.template_id.default_image_style
def action_prepare_items(self):
"""Parse source and ADD items to batch (preserves existing items)."""
self.ensure_one()
# Collect existing items to avoid duplicates
existing_keys = set()
for item in self.item_ids:
if item.product_id:
existing_keys.add(f"product_{item.product_id.id}")
elif item.keywords:
existing_keys.add(f"keywords_{item.keywords.lower().strip()}")
items_data = []
if self.source_type == 'products':
if not self.product_ids:
raise UserError(_("Please select at least one product."))
for product in self.product_ids:
key = f"product_{product.id}"
if key not in existing_keys:
items_data.append({
'batch_id': self.id,
'name': product.name,
'product_id': product.id,
'keywords': product.name,
'topic': self._prepare_product_brief(product),
})
# Clear product selection after adding
self.product_ids = [(5, 0, 0)]
elif self.source_type == 'csv':
if not self.csv_file:
raise UserError(_("Please upload a CSV file."))
csv_items = self._parse_csv()
for item in csv_items:
key = f"keywords_{item.get('keywords', '').lower().strip()}"
if key not in existing_keys:
item['batch_id'] = self.id
items_data.append(item)
# Clear CSV after adding
self.csv_file = False
self.csv_filename = False
elif self.source_type == 'keywords':
if not self.keywords_list:
raise UserError(_("Please enter keywords or topics."))
for line in self.keywords_list.strip().split('\n'):
line = line.strip()
if line:
key = f"keywords_{line.lower()}"
if key not in existing_keys:
items_data.append({
'batch_id': self.id,
'name': line[:100],
'keywords': line,
})
# Clear keywords list after adding
self.keywords_list = False
if not items_data:
raise UserError(_("No new items to add. Items may already exist in the batch."))
# Create new batch items
self.env['otk.seo.content.batch.item'].create(items_data)
def action_clear_items(self):
"""Remove all items from the batch."""
self.ensure_one()
if self.state not in ('draft',):
raise UserError(_("Can only clear items when batch is in Draft state."))
self.item_ids.unlink()
def _prepare_product_brief(self, product):
"""Prepare product information for AI."""
brief = f"Product: {product.name}\n"
if product.description:
brief += f"Description: {product.description}\n"
if product.list_price:
brief += f"Price: {product.list_price}\n"
if product.categ_id:
brief += f"Category: {product.categ_id.complete_name}\n"
return brief
def _parse_csv(self):
"""Parse CSV file and return items data (without batch_id)."""
items_data = []
try:
csv_data = base64.b64decode(self.csv_file).decode('utf-8')
reader = csv.DictReader(io.StringIO(csv_data))
for row in reader:
# Expected columns: keywords, topic (optional), name (optional)
keywords = row.get('keywords', row.get('keyword', ''))
topic = row.get('topic', row.get('brief', ''))
name = row.get('name', keywords[:100] if keywords else 'Untitled')
if keywords or topic:
items_data.append({
'name': name,
'keywords': keywords,
'topic': topic,
})
except Exception as e:
raise UserError(_("Error parsing CSV file: %s") % str(e))
if not items_data:
raise UserError(_("No valid items found in CSV. Expected columns: keywords, topic (optional)"))
return items_data
def action_start_batch(self):
"""Queue the batch for async processing via cron."""
self.ensure_one()
if not self.item_ids:
self.action_prepare_items()
if not self.item_ids:
raise UserError(_("No items to process."))
self.write({
'state': 'queued',
'started_at': fields.Datetime.now(),
})
# Mark all items as pending - they will be picked up by the cron job
self.item_ids.write({'state': 'pending'})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Batch Queued'),
'message': _('Batch has been queued for processing. Items will be processed asynchronously.'),
'type': 'success',
'sticky': False,
}
}
def action_pause(self):
"""Pause the batch processing."""
self.ensure_one()
if self.state in ('queued', 'processing'):
self.write({'state': 'paused'})
def action_resume(self):
"""Resume paused batch processing."""
self.ensure_one()
if self.state == 'paused':
self.write({'state': 'processing'})
def action_cancel(self):
"""Cancel the batch."""
self.ensure_one()
if self.state in ('queued', 'processing', 'paused'):
self.write({'state': 'cancelled'})
self.item_ids.filtered(lambda i: i.state == 'pending').write({'state': 'cancelled'})
return True
@api.model
def cron_process_batch_items(self):
"""Cron job to process batch items asynchronously.
Processes up to `concurrency` items per active batch per cron run.
Skips paused and cancelled batches.
"""
# Find all batches that are queued or processing (not paused)
active_batches = self.search([
('state', 'in', ['queued', 'processing']),
])
for batch in active_batches:
# Skip if cancelled or paused
if batch.state in ('cancelled', 'paused'):
continue
# Get pending items up to concurrency limit
items_limit = min(batch.concurrency or 5, 10)
pending_items = batch.item_ids.filtered(lambda i: i.state == 'pending')[:items_limit]
if not pending_items:
# All items processed, complete the batch
batch._complete_batch()
continue
# Update batch state to processing if it was queued
if batch.state == 'queued':
batch.state = 'processing'
# Process items (with commit after each to preserve state)
for item in pending_items:
try:
item._process_item()
self.env.cr.commit()
except Exception as e:
_logger.error("Error processing batch item %s: %s", item.id, e)
item.write({
'state': 'failed',
'error_message': str(e),
})
self.env.cr.commit()
return True
def _complete_batch(self):
"""Mark batch as completed."""
self.ensure_one()
failed = self.item_ids.filtered(lambda i: i.state == 'failed')
total = len(self.item_ids)
if total == 0:
state = 'done'
elif len(failed) == total:
state = 'failed'
else:
state = 'done'
self.write({
'state': state,
'completed_at': fields.Datetime.now(),
})
# Send bus notification to batch creator
if self.create_uid and self.create_uid.partner_id:
succeeded = len(self.item_ids) - len(failed)
self.env['bus.bus']._sendone(
self.create_uid.partner_id,
'simple_notification',
{
'title': _("Batch Complete!"),
'message': _("Batch '%s' complete: %d/%d items succeeded.") % (
self.name, succeeded, len(self.item_ids)),
'type': 'success' if not failed else 'warning',
'sticky': False,
}
)
def action_view_content(self):
"""View generated content."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Generated Content'),
'res_model': 'otk.seo.content',
'view_mode': 'list,form',
'domain': [('batch_id', '=', self.id)],
'context': {'default_batch_id': self.id},
}
def action_retry_failed(self):
"""Retry failed items - re-queue them for async processing."""
self.ensure_one()
failed_items = self.item_ids.filtered(lambda i: i.state == 'failed')
if not failed_items:
raise UserError(_("No failed items to retry."))
failed_items.write({'state': 'pending', 'error_message': False})
self.write({'state': 'queued'})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Items Re-queued'),
'message': _('%d failed items have been re-queued for processing.') % len(failed_items),
'type': 'success',
'sticky': False,
}
}
class SeoContentBatchItem(models.Model):
_name = 'otk.seo.content.batch.item'
_description = 'SEO Content Batch Item'
_order = 'sequence, id'
batch_id = fields.Many2one('otk.seo.content.batch', string='Batch',
required=True, ondelete='cascade')
sequence = fields.Integer('Sequence', default=10)
name = fields.Char('Name', required=True)
state = fields.Selection([
('draft', 'Draft'),
('pending', 'Pending'),
('processing', 'Processing'),
('done', 'Completed'),
('failed', 'Failed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft')
# === Source Data ===
product_id = fields.Many2one('product.template', 'Product')
keywords = fields.Char('Keywords')
topic = fields.Text('Topic/Brief')
# === Result ===
content_id = fields.Many2one('otk.seo.content', 'Generated Content', ondelete='set null')
error_message = fields.Text('Error Message')
def _process_item(self):
"""Process this batch item - generate content."""
self.ensure_one()
self.state = 'processing'
try:
batch = self.batch_id
# Create content record
content_vals = {
'name': self.name,
'content_type': batch.content_type,
'source_keywords': self.keywords,
'source_topic': self.topic,
'source_product_id': self.product_id.id if self.product_id else False,
'template_id': batch.template_id.id if batch.template_id else False,
'brand_voice_id': batch.brand_voice_id.id if batch.brand_voice_id else False,
'tone': batch.tone,
'target_word_count': batch.target_word_count,
'language_id': batch.language_id.id if batch.language_id else False,
'requested_image_count': batch.image_count if batch.include_images else 0,
'image_style': batch.image_style if batch.include_images else False,
'blog_id': batch.blog_id.id if batch.blog_id else False,
'batch_id': batch.id,
'state': 'draft',
}
content = self.env['otk.seo.content'].create(content_vals)
self.content_id = content.id
# Generate content
content.action_generate()
self.state = 'done'
_logger.info(f"Batch item {self.id} processed successfully: content {content.id}")
except Exception as e:
self.state = 'failed'
self.error_message = str(e)
_logger.error(f"Batch item {self.id} failed: {e}")
return True
@@ -0,0 +1,420 @@
import logging
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OtkSeoContentIdea(models.Model):
_name = 'otk.seo.content.idea'
_description = 'SEO Content Idea Queue'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'scheduled_date, priority desc, create_date'
name = fields.Char('Title/Topic', required=True, tracking=True,
help='Working title or topic for this content idea.')
# === Source Information ===
keywords = fields.Char('Target Keywords',
help='Primary keywords for this content.')
topic_brief = fields.Text('Topic Brief',
help='Description of what the content should cover.')
product_id = fields.Many2one('product.template', 'Related Product',
help='Product to generate content about.')
reference_ids = fields.Many2many('otk.seo.reference',
'otk_seo_idea_reference_rel', 'idea_id', 'reference_id',
string='References',
help='Reference materials to provide context for content generation.')
# === Priority & Scheduling ===
priority = fields.Selection([
('0', 'Low'),
('1', 'Normal'),
('2', 'High'),
('3', 'Urgent'),
], string='Priority', default='1', index=True, tracking=True,
help='Priority level for processing. Higher priority ideas are processed first.')
scheduled_date = fields.Datetime('Scheduled For', tracking=True,
help='When this content should be generated. Leave empty for manual processing only.')
# === Generation Settings ===
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', default='blog_post', required=True,
help='The type of content to generate.')
template_id = fields.Many2one('otk.seo.template', 'Content Template',
domain="[('content_type', '=', content_type)]",
help='Template to use for content generation.')
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.')
tone = fields.Selection([
('professional', 'Professional'),
('casual', 'Casual'),
('technical', 'Technical'),
('persuasive', 'Persuasive'),
('conversational', 'Conversational'),
], string='Tone', default='professional',
help='The writing style for generated content.')
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='Approximate length of generated content.')
language_id = fields.Many2one('res.lang', 'Language',
default=lambda self: self._default_language(),
help='Language for content generation.')
# === Image Settings ===
include_images = fields.Boolean('Generate Images', default=True,
help='Generate images along with the content.')
image_count = fields.Integer('Image Count', default=1,
help='Number of images to generate.')
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.')
# === Auto-Publish Settings ===
auto_create_blog_post = fields.Boolean('Auto-Create Blog Post',
help='Automatically create a blog post when content is generated and approved.')
blog_id = fields.Many2one('blog.blog', 'Target Blog',
help='Blog where the post will be created.')
auto_publish = fields.Boolean('Auto-Publish Post',
help='Automatically publish the blog post (make it visible on the website).')
# === State ===
state = fields.Selection([
('idea', 'Idea'),
('scheduled', 'Scheduled'),
('generating', 'Generating'),
('done', 'Done'),
('failed', 'Failed'),
('cancelled', 'Cancelled'),
], string='Status', default='idea', tracking=True, index=True, copy=False,
help='Current status of the idea.')
# === Result ===
content_id = fields.Many2one('otk.seo.content', 'Generated Content',
readonly=True, ondelete='set null', copy=False,
help='The content record created from this idea.')
blog_post_id = fields.Many2one('blog.post', 'Blog Post',
related='content_id.blog_post_id', readonly=True,
help='The blog post created from the generated content.')
error_message = fields.Text('Error Message', copy=False,
help='Error details if generation failed.')
# === Recurring ===
is_recurring = fields.Boolean('Recurring',
help='If enabled, a new idea will be automatically created after this one is processed.')
recurrence_type = fields.Selection([
('daily', 'Daily'),
('weekly', 'Weekly'),
('biweekly', 'Every 2 Weeks'),
('monthly', 'Monthly'),
], string='Recurrence', default='weekly',
help='How often a new idea should be created.')
recurrence_end_date = fields.Date('Recurrence End Date',
help='Stop creating new occurrences after this date. Leave empty for no end date.')
parent_idea_id = fields.Many2one('otk.seo.content.idea', 'Parent Idea',
readonly=True, ondelete='set null', copy=False,
help='The recurring idea that spawned this occurrence.')
# === Tracking ===
generated_at = fields.Datetime('Generated At', readonly=True, copy=False,
help='When the content was successfully generated.')
def _default_language(self):
"""Get default language."""
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('scheduled_date')
def _onchange_scheduled_date(self):
"""Update state based on scheduled date."""
for record in self:
if record.scheduled_date and record.state == 'idea':
record.state = 'scheduled'
elif not record.scheduled_date and record.state == 'scheduled':
record.state = 'idea'
def action_schedule_now(self):
"""Schedule this idea for immediate generation by cron."""
self.ensure_one()
if self.state not in ('idea', 'scheduled'):
raise UserError(_("Can only schedule ideas that are in 'Idea' or 'Scheduled' state."))
self.write({
'scheduled_date': fields.Datetime.now(),
'state': 'scheduled',
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Scheduled'),
'message': _('Idea scheduled for immediate processing.'),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
}
}
def action_generate_now(self):
"""Generate content immediately (bypass cron schedule)."""
self.ensure_one()
if self.state not in ('idea', 'scheduled'):
raise UserError(_("Can only generate from 'Idea' or 'Scheduled' state."))
self._process_idea()
if self.state == 'done' and self.content_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'otk.seo.content',
'res_id': self.content_id.id,
'view_mode': 'form',
'target': 'current',
}
return True
def action_cancel(self):
"""Cancel this idea."""
for record in self:
if record.state in ('idea', 'scheduled'):
record.write({'state': 'cancelled'})
def action_reset_to_idea(self):
"""Reset failed/cancelled idea back to idea state."""
for record in self:
if record.state in ('failed', 'cancelled'):
record.write({
'state': 'idea',
'scheduled_date': False,
'error_message': False,
})
def action_view_content(self):
"""Open the generated content."""
self.ensure_one()
if not self.content_id:
raise UserError(_("No content has been generated yet."))
return {
'type': 'ir.actions.act_window',
'res_model': 'otk.seo.content',
'res_id': self.content_id.id,
'view_mode': 'form',
'target': 'current',
}
def _process_idea(self):
"""Submit content generation for this idea (async).
Creates content record and submits for generation. The content
will be generated asynchronously by the API. A separate cron job
monitors idea completion.
"""
self.ensure_one()
self.write({
'state': 'generating',
'error_message': False,
})
try:
# Build content values
content_vals = {
'name': self.name,
'content_type': self.content_type,
'source_keywords': self.keywords,
'source_topic': self.topic_brief,
'source_product_id': self.product_id.id if self.product_id else False,
'template_id': self.template_id.id if self.template_id else False,
'brand_voice_id': self.brand_voice_id.id if self.brand_voice_id else False,
'tone': self.tone,
'target_word_count': self.target_word_count,
'language_id': self.language_id.id if self.language_id else False,
'requested_image_count': self.image_count if self.include_images else 0,
'image_style': self.image_style if self.include_images else False,
'blog_id': self.blog_id.id if self.blog_id else False,
'reference_ids': [(6, 0, self.reference_ids.ids)] if self.reference_ids else False,
}
# Create content record
content = self.env['otk.seo.content'].create(content_vals)
self.content_id = content.id
# Submit content for async generation
content.action_generate()
# Create next occurrence for recurring ideas
self._create_next_occurrence()
# Content is now in 'generating' state - cron will check completion
_logger.info(f"Idea {self.id} '{self.name}' submitted for content generation: content {content.id}")
except Exception as e:
_logger.exception(f"Idea {self.id} failed: {e}")
self.write({
'state': 'failed',
'error_message': str(e),
})
def _create_next_occurrence(self):
"""Create the next occurrence if this is a recurring idea."""
self.ensure_one()
if not self.is_recurring:
return
# Check if recurrence has ended
if self.recurrence_end_date and fields.Date.today() >= self.recurrence_end_date:
_logger.info("Recurring idea %s reached end date %s, no more occurrences",
self.id, self.recurrence_end_date)
return
# Compute next scheduled date
base_date = self.scheduled_date or fields.Datetime.now()
interval_map = {
'daily': timedelta(days=1),
'weekly': timedelta(weeks=1),
'biweekly': timedelta(weeks=2),
'monthly': timedelta(days=30),
}
delta = interval_map.get(self.recurrence_type, timedelta(weeks=1))
next_date = base_date + delta
# Check if next date exceeds end date
if self.recurrence_end_date and next_date.date() > self.recurrence_end_date:
_logger.info("Next occurrence for idea %s would exceed end date, skipping", self.id)
return
# Create next occurrence
next_idea = self.copy({
'scheduled_date': next_date,
'state': 'scheduled',
'parent_idea_id': self.id,
'is_recurring': True,
'recurrence_type': self.recurrence_type,
'recurrence_end_date': self.recurrence_end_date,
})
_logger.info("Created recurring occurrence %s for idea %s, scheduled at %s",
next_idea.id, self.id, next_date)
def _check_content_completion(self):
"""Check if linked content has completed and finalize idea if so."""
self.ensure_one()
if not self.content_id:
return False
content = self.content_id
# Check if content has finished generating
if content.state in ('review', 'images_pending', 'approved', 'published'):
# Auto-approve and create blog post if configured
if self.auto_create_blog_post and content.state == 'review':
content.action_approve()
content.action_create_blog_post()
# Auto-publish if enabled
if self.auto_publish and content.blog_post_id:
content.blog_post_id.is_published = True
self.write({
'state': 'done',
'generated_at': fields.Datetime.now(),
})
_logger.info(f"Idea {self.id} '{self.name}' completed: content {content.id}")
return True
elif content.state == 'draft' and content.error_message:
# Content generation failed
self.write({
'state': 'failed',
'error_message': content.error_message,
})
return True
# Still generating
return False
@api.model
def cron_process_scheduled_ideas(self):
"""Cron job to process scheduled ideas.
Runs periodically. Processes ideas whose scheduled_date has passed.
"""
now = fields.Datetime.now()
scheduled_ideas = self.search([
('state', '=', 'scheduled'),
('scheduled_date', '<=', now),
], order='priority desc, scheduled_date', limit=5) # Process up to 5 per run
_logger.info(f"Processing {len(scheduled_ideas)} scheduled ideas")
for idea in scheduled_ideas:
try:
idea._process_idea()
# Commit after each to prevent full rollback on error
self.env.cr.commit()
except Exception as e:
_logger.error(f"Error processing scheduled idea {idea.id}: {e}")
idea.write({
'state': 'failed',
'error_message': str(e),
})
self.env.cr.commit()
return True
@api.model
def cron_check_generating_ideas(self):
"""Cron job to check if generating ideas have completed.
Runs periodically. Checks ideas in 'generating' state and
marks them done/failed based on linked content status.
"""
generating_ideas = self.search([
('state', '=', 'generating'),
('content_id', '!=', False),
], limit=20)
_logger.info(f"Checking {len(generating_ideas)} generating ideas")
for idea in generating_ideas:
try:
idea._check_content_completion()
except Exception as e:
_logger.error(f"Error checking idea {idea.id} completion: {e}")
return True
def copy(self, default=None):
"""Reset state when duplicating."""
default = dict(default or {})
default.setdefault('state', 'idea')
default.setdefault('scheduled_date', False)
default.setdefault('content_id', False)
default.setdefault('error_message', False)
default.setdefault('generated_at', False)
return super().copy(default)
@@ -0,0 +1,728 @@
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'},
}
}
@@ -0,0 +1,453 @@
import base64
import logging
import re
import requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import html2plaintext
_logger = logging.getLogger(__name__)
MAX_REFERENCE_CONTENT_LENGTH = 50000
URL_FETCH_TIMEOUT = 30
# Optional dependencies for better HTML parsing
try:
from bs4 import BeautifulSoup
HAS_BEAUTIFULSOUP = True
except ImportError:
HAS_BEAUTIFULSOUP = False
_logger.info("BeautifulSoup not installed. Basic HTML parsing will be used for URL fetching.")
class OtkSeoReferenceTag(models.Model):
_name = 'otk.seo.reference.tag'
_description = 'SEO Reference Tag'
_order = 'name'
name = fields.Char('Tag Name', required=True, translate=True)
color = fields.Integer('Color Index', default=0)
@api.constrains('name')
def _check_name_unique(self):
for record in self:
if record.name:
duplicate = self.search([
('name', '=', record.name),
('id', '!=', record.id),
], limit=1)
if duplicate:
raise ValidationError(_('Tag name must be unique!'))
class OtkSeoReference(models.Model):
_name = 'otk.seo.reference'
_description = 'SEO Reference Library'
_order = 'sequence, name'
# === Identification ===
name = fields.Char('Reference Name', required=True, translate=True,
help='A descriptive name for this reference.')
reference_type = fields.Selection([
('internal_content', 'Internal Content'),
('url', 'URL'),
('text', 'Text'),
('file', 'File'),
], string='Type', required=True, default='text',
help='The type of reference material.')
# === Type-Specific Fields ===
internal_content_id = fields.Many2one('otk.seo.content', 'Internal Content',
domain="[('state', 'in', ['review', 'approved', 'published'])]",
help='Link to existing SEO content to use as reference.')
url = fields.Char('URL',
help='External URL to use as reference. Content will be fetched and stored locally.')
# === URL Fetching ===
url_fetched_content = fields.Text('Fetched Content',
help='Content extracted from the URL. Auto-fetched when URL is set.')
url_fetch_date = fields.Datetime('Last Fetched',
help='When the URL content was last fetched.')
url_fetch_status = fields.Selection([
('pending', 'Pending'),
('fetching', 'Fetching'),
('success', 'Success'),
('error', 'Error'),
], string='Fetch Status', default='pending',
help='Status of the URL content fetch operation.')
url_fetch_error = fields.Char('Fetch Error',
help='Error message if URL fetch failed.')
url_content_type = fields.Char('Content Type',
help='MIME type of the fetched content.')
text_content = fields.Text('Text Content',
help='Raw text content to use as reference material.')
file = fields.Binary('File', attachment=True,
help='Upload a document (PDF, Word, or text file) to use as reference.')
file_name = fields.Char('File Name')
# === Organization ===
content_type = fields.Selection([
('blog_post', 'Blog Post'),
('product_desc', 'Product Description'),
('category_desc', 'Category Description'),
('landing_page', 'Landing Page Content'),
('all', 'All Types'),
], string='For Content Type', default='all',
help='Limit this reference to specific content types, or make available for all.')
tag_ids = fields.Many2many('otk.seo.reference.tag',
'otk_seo_reference_tag_rel', 'reference_id', 'tag_id',
string='Tags', help='Tags for organizing and filtering references.')
# === Metadata ===
description = fields.Text('Description', translate=True,
help='Brief description of what this reference contains and when to use it.')
active = fields.Boolean('Active', default=True)
sequence = fields.Integer('Sequence', default=10)
# === Computed ===
content_preview = fields.Text('Content Preview', compute='_compute_content_preview',
help='Preview of the extracted content.')
content_length = fields.Integer('Content Length', compute='_compute_content_preview',
help='Length of extractable content in characters.')
@api.depends('reference_type', 'internal_content_id', 'url', 'text_content', 'file',
'url_fetched_content', 'url_fetch_status')
def _compute_content_preview(self):
for record in self:
content = record._extract_content()
record.content_length = len(content)
if len(content) > 500:
record.content_preview = content[:500] + '...'
else:
record.content_preview = content
@api.constrains('reference_type', 'internal_content_id', 'url', 'text_content', 'file')
def _check_required_fields(self):
"""Ensure required fields are filled based on reference type."""
for record in self:
if record.reference_type == 'internal_content' and not record.internal_content_id:
raise UserError(_("Please select an internal content for this reference."))
elif record.reference_type == 'url' and not record.url:
raise UserError(_("Please provide a URL for this reference."))
elif record.reference_type == 'text' and not record.text_content:
raise UserError(_("Please provide text content for this reference."))
elif record.reference_type == 'file' and not record.file:
raise UserError(_("Please upload a file for this reference."))
@api.onchange('reference_type')
def _onchange_reference_type(self):
"""Clear irrelevant fields when type changes."""
if self.reference_type != 'internal_content':
self.internal_content_id = False
if self.reference_type != 'url':
self.url = False
self.url_fetched_content = False
self.url_fetch_date = False
self.url_fetch_status = 'pending'
self.url_fetch_error = False
self.url_content_type = False
if self.reference_type != 'text':
self.text_content = False
if self.reference_type != 'file':
self.file = False
self.file_name = False
def _extract_content(self):
"""Extract text content from reference based on type."""
self.ensure_one()
if self.reference_type == 'url':
# Return fetched content if available, otherwise indicate pending
if self.url_fetched_content and self.url_fetch_status == 'success':
return self.url_fetched_content
elif self.url_fetch_status == 'error':
return f"[URL fetch error: {self.url_fetch_error or 'Unknown error'}]"
elif self.url_fetch_status == 'fetching':
return f"[Fetching content from: {self.url}...]"
else:
return f"[Content pending fetch from: {self.url}]"
elif self.reference_type == 'text':
return self.text_content or ''
elif self.reference_type == 'internal_content':
if self.internal_content_id and self.internal_content_id.generated_content:
return html2plaintext(self.internal_content_id.generated_content)
return ''
elif self.reference_type == 'file':
return self._extract_file_content()
return ''
def _extract_file_content(self):
"""Extract text content from uploaded file."""
self.ensure_one()
if not self.file:
return ''
try:
file_data = base64.b64decode(self.file)
file_name_lower = (self.file_name or '').lower()
# Plain text
if file_name_lower.endswith('.txt'):
return file_data.decode('utf-8', errors='ignore')
# PDF
elif file_name_lower.endswith('.pdf'):
try:
import PyPDF2
from io import BytesIO
reader = PyPDF2.PdfReader(BytesIO(file_data))
text_parts = []
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
return '\n'.join(text_parts)
except ImportError:
_logger.warning("PyPDF2 not installed for PDF extraction")
return _('[PDF content - install PyPDF2 for extraction]')
except Exception as e:
_logger.error(f"PDF extraction error: {e}")
return _('[PDF extraction failed]')
# Word documents
elif file_name_lower.endswith(('.docx', '.doc')):
try:
from docx import Document
from io import BytesIO
doc = Document(BytesIO(file_data))
return '\n'.join(para.text for para in doc.paragraphs if para.text)
except ImportError:
_logger.warning("python-docx not installed for Word extraction")
return _('[Word content - install python-docx for extraction]')
except Exception as e:
_logger.error(f"Word extraction error: {e}")
return _('[Word extraction failed]')
else:
# Try as plain text
return file_data.decode('utf-8', errors='ignore')
except Exception as e:
_logger.error(f"File content extraction error: {e}")
return ''
def get_api_payload(self):
"""Build API payload for this reference."""
self.ensure_one()
content = self._extract_content()
# Truncate if too long
if len(content) > MAX_REFERENCE_CONTENT_LENGTH:
content = content[:MAX_REFERENCE_CONTENT_LENGTH]
_logger.info(f"Reference {self.id} content truncated to {MAX_REFERENCE_CONTENT_LENGTH} chars")
return {
'type': self.reference_type,
'title': self.name,
'content': content,
'source': 'internal' if self.reference_type == 'internal_content' else self.reference_type,
}
# === URL Fetching Methods ===
def action_fetch_url_content(self):
"""Fetch content from URL and store it."""
self.ensure_one()
if self.reference_type != 'url' or not self.url:
raise UserError(_("This reference is not a URL type or URL is empty."))
self.write({'url_fetch_status': 'fetching', 'url_fetch_error': False})
try:
headers = {
'User-Agent': 'Mozilla/5.0 (compatible; OToolKit/1.0; +https://otoolkit.app)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
response = requests.get(
self.url,
headers=headers,
timeout=URL_FETCH_TIMEOUT,
allow_redirects=True
)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
if 'text/html' in content_type:
text_content = self._parse_html_content(response.text)
elif 'text/plain' in content_type:
text_content = response.text
elif 'application/json' in content_type:
text_content = response.text
else:
# Try to decode as text anyway
try:
text_content = response.text
except Exception:
text_content = f"[Unsupported content type: {content_type}]"
# Clean and truncate if needed
text_content = self._clean_text_content(text_content)
if len(text_content) > MAX_REFERENCE_CONTENT_LENGTH:
text_content = text_content[:MAX_REFERENCE_CONTENT_LENGTH]
_logger.info(f"URL content truncated to {MAX_REFERENCE_CONTENT_LENGTH} chars for reference {self.id}")
self.write({
'url_fetched_content': text_content,
'url_fetch_date': fields.Datetime.now(),
'url_fetch_status': 'success',
'url_fetch_error': False,
'url_content_type': content_type[:100] if content_type else False,
})
_logger.info(f"Successfully fetched {len(text_content)} chars from {self.url} for reference {self.id}")
except requests.exceptions.Timeout:
self._set_url_fetch_error(_("Request timed out after %d seconds.") % URL_FETCH_TIMEOUT)
except requests.exceptions.TooManyRedirects:
self._set_url_fetch_error(_("Too many redirects."))
except requests.exceptions.SSLError as e:
self._set_url_fetch_error(_("SSL Error: %s") % str(e)[:100])
except requests.exceptions.ConnectionError:
self._set_url_fetch_error(_("Could not connect to URL."))
except requests.exceptions.HTTPError as e:
self._set_url_fetch_error(_("HTTP Error %s") % e.response.status_code if e.response else str(e)[:100])
except Exception as e:
_logger.error(f"URL fetch error for reference {self.id}: {e}")
self._set_url_fetch_error(str(e)[:200])
def _set_url_fetch_error(self, error_message):
"""Set URL fetch error state."""
self.write({
'url_fetch_status': 'error',
'url_fetch_error': error_message[:255] if error_message else 'Unknown error',
'url_fetch_date': fields.Datetime.now(),
})
def _parse_html_content(self, html_text):
"""Parse HTML and extract readable text content."""
if HAS_BEAUTIFULSOUP:
return self._parse_html_with_beautifulsoup(html_text)
else:
return self._parse_html_basic(html_text)
def _parse_html_with_beautifulsoup(self, html_text):
"""Parse HTML using BeautifulSoup for better extraction."""
soup = BeautifulSoup(html_text, 'html.parser')
# Remove script, style, nav, footer, header, aside elements
for element in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'iframe']):
element.decompose()
# Try to find main content area
main_content = (
soup.find('main') or
soup.find('article') or
soup.find('div', {'class': re.compile(r'content|main|post|article', re.I)}) or
soup.find('div', {'id': re.compile(r'content|main|post|article', re.I)}) or
soup.find('body')
)
if main_content:
# Get text with some structure preserved
text_parts = []
for element in main_content.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td', 'th', 'blockquote']):
text = element.get_text(strip=True)
if text:
# Add markers for headings
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
text_parts.append(f"\n## {text}\n")
elif element.name == 'li':
text_parts.append(f"{text}")
else:
text_parts.append(text)
return '\n'.join(text_parts)
else:
return soup.get_text(separator='\n', strip=True)
def _parse_html_basic(self, html_text):
"""Basic HTML parsing without BeautifulSoup."""
# Remove script and style content
html_text = re.sub(r'<script[^>]*>.*?</script>', '', html_text, flags=re.DOTALL | re.IGNORECASE)
html_text = re.sub(r'<style[^>]*>.*?</style>', '', html_text, flags=re.DOTALL | re.IGNORECASE)
# Convert some tags to text markers
html_text = re.sub(r'<h[1-6][^>]*>(.*?)</h[1-6]>', r'\n## \1\n', html_text, flags=re.DOTALL | re.IGNORECASE)
html_text = re.sub(r'<li[^>]*>(.*?)</li>', r'\1\n', html_text, flags=re.DOTALL | re.IGNORECASE)
html_text = re.sub(r'<p[^>]*>(.*?)</p>', r'\1\n\n', html_text, flags=re.DOTALL | re.IGNORECASE)
html_text = re.sub(r'<br\s*/?>', '\n', html_text, flags=re.IGNORECASE)
# Remove remaining tags
html_text = re.sub(r'<[^>]+>', '', html_text)
# Decode HTML entities
html_text = html_text.replace('&nbsp;', ' ')
html_text = html_text.replace('&amp;', '&')
html_text = html_text.replace('&lt;', '<')
html_text = html_text.replace('&gt;', '>')
html_text = html_text.replace('&quot;', '"')
return html_text
def _clean_text_content(self, text):
"""Clean extracted text content."""
if not text:
return ''
# Normalize whitespace
text = re.sub(r'[ \t]+', ' ', text)
# Remove excessive newlines
text = re.sub(r'\n{3,}', '\n\n', text)
# Strip each line
lines = [line.strip() for line in text.split('\n')]
# Remove empty lines at start/end
text = '\n'.join(lines).strip()
return text
# === Auto-fetch on Create/Write ===
@api.model_create_multi
def create(self, vals_list):
"""Auto-fetch URL content for new URL references."""
records = super().create(vals_list)
for record in records:
if record.reference_type == 'url' and record.url:
try:
record.action_fetch_url_content()
except Exception as e:
_logger.warning(f"Auto-fetch failed for new reference {record.id}: {e}")
return records
def write(self, vals):
"""Auto-fetch URL content when URL changes."""
result = super().write(vals)
# Re-fetch if URL changed for URL type references
if 'url' in vals and vals.get('url'):
for record in self:
if record.reference_type == 'url':
try:
record.action_fetch_url_content()
except Exception as e:
_logger.warning(f"Auto-fetch failed for reference {record.id}: {e}")
return result
@@ -0,0 +1,120 @@
from odoo import api, fields, models, _
class OtkSeoTemplate(models.Model):
_name = 'otk.seo.template'
_description = 'SEO Content Template'
_order = 'sequence, name'
name = fields.Char('Template Name', required=True, translate=True,
help='A descriptive name for this template (e.g., "How-To Guide", "Product Review").')
sequence = fields.Integer('Sequence', default=10,
help='Order in which templates appear in selection lists. Lower numbers appear first.')
active = fields.Boolean('Active', default=True,
help='If unchecked, this template will not be available for selection.')
content_type = fields.Selection([
('blog_post', 'Blog Post'),
('product_desc', 'Product Description'),
('category_desc', 'Category Description'),
('landing_page', 'Landing Page Content'),
], string='Content Type', required=True, default='blog_post',
help='The type of content this template is designed for. Templates only appear for matching content types.')
description = fields.Text('Description', translate=True,
help='Explanation of when and how to use this template. Shown to users when selecting a template.')
# === Prompt Configuration ===
system_prompt = fields.Text('System Prompt',
help='Background instructions for the AI defining its role, expertise, and constraints. This shapes the overall writing style and approach.')
content_prompt_template = fields.Text('Content Prompt Template',
help='The main prompt template sent to the AI. Use placeholders: {keywords}, {topic}, {product_name}, {tone}, {word_count}, {language}. These will be replaced with actual values.')
title_prompt_template = fields.Text('Title Prompt Template',
help='Template for generating the content title. Use placeholders to customize based on keywords or topic.')
meta_prompt_template = fields.Text('Meta Description Prompt Template',
help='Template for generating the SEO meta description. Should produce text between 120-160 characters.')
# === Defaults ===
default_tone = fields.Selection([
('professional', 'Professional'),
('casual', 'Casual'),
('technical', 'Technical'),
('persuasive', 'Persuasive'),
('conversational', 'Conversational'),
], string='Default Tone', default='professional',
help='The default writing tone when using this template. Users can override this when generating content.')
default_word_count = fields.Selection([
('short', 'Short (300-500 words)'),
('medium', 'Medium (500-1000 words)'),
('long', 'Long (1000-2000 words)'),
], string='Default Length', default='medium',
help='The default target word count when using this template. Users can override this when generating content.')
# === Image Generation ===
include_images = fields.Boolean('Include Image Generation', default=True,
help='If enabled, image generation will be included by default when using this template.')
default_image_count = fields.Integer('Default Image Count', default=1,
help='Default number of images to generate when using this template.')
image_prompt_template = fields.Text('Image Prompt Template',
help='Template for generating image prompts. Use placeholders: {title}, {keywords}, {section}. The AI will use this to create relevant images.')
default_image_style = fields.Selection([
('photorealistic', 'Photorealistic'),
('illustration', 'Illustration'),
('digital_art', 'Digital Art'),
('watercolor', 'Watercolor'),
('sketch', 'Sketch'),
('3d_render', '3D Render'),
], string='Default Image Style', default='photorealistic',
help='The default visual style for generated images when using this template.')
# === Formatting Options ===
include_toc = fields.Boolean('Include Table of Contents',
help='If enabled, the AI will generate a table of contents at the beginning of the content. Best for longer articles.')
include_faq = fields.Boolean('Include FAQ Section',
help='If enabled, the AI will add a FAQ section at the end. Good for SEO and covering common questions.')
include_cta = fields.Boolean('Include Call-to-Action',
help='If enabled, the AI will include a call-to-action at the end of the content.')
header_structure = fields.Selection([
('h2_only', 'H2 Headers Only'),
('h2_h3', 'H2 and H3 Headers'),
('h2_h3_h4', 'H2, H3, and H4 Headers'),
], string='Header Structure', default='h2_h3',
help='Heading hierarchy to use in the content. H2/H3 is recommended for most content; simpler structure for short content, deeper nesting for comprehensive guides.')
# === Reference Library ===
reference_ids = fields.Many2many('otk.seo.reference',
'otk_seo_template_reference_rel', 'template_id', 'reference_id',
string='Default References',
domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]",
help='References to include by default when using this template. Users can add or remove references when generating content.')
# === Statistics ===
usage_count = fields.Integer('Times Used', compute='_compute_usage_count',
help='Number of content items created using this template.')
def _compute_usage_count(self):
if not self.ids:
return
data = self.env['otk.seo.content']._read_group(
[('template_id', 'in', self.ids)],
['template_id'],
['__count'],
)
counts = {template.id: count for template, count in data}
for record in self:
record.usage_count = counts.get(record.id, 0)
def action_view_contents(self):
"""View contents created with this template."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'otk.seo.content',
'view_mode': 'list,form',
'domain': [('template_id', '=', self.id)],
'name': _('Contents using %s') % self.name,
}