421 lines
16 KiB
Python
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)
|