Files
seo/otoolkit_seo_content/models/seo_content_idea.py
T

421 lines
16 KiB
Python

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)