Refactor: Move SEO content files to root of module

This commit is contained in:
2026-07-12 07:08:33 +02:00
parent 8e01c7c8ec
commit 45c65e24dd
99 changed files with 0 additions and 3739 deletions
@@ -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