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)