Seo
This commit is contained in:
@@ -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(' ', ' ')
|
||||
html_text = html_text.replace('&', '&')
|
||||
html_text = html_text.replace('<', '<')
|
||||
html_text = html_text.replace('>', '>')
|
||||
html_text = html_text.replace('"', '"')
|
||||
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user