# -*- coding: utf-8 -*- """ itsulu.blog.schedule — Three configurable daily cron slots. This model owns the full generation orchestration: it calls LLMRouter, creates the blog.post, sets SEO fields, creates the social copy record, optionally publishes, and sends the notification email. One model — three records (morning, afternoon, evening) configured in data/ir_cron_data.xml. Additional slots can be created manually. """ import logging import time import base64 from odoo import api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class BlogSchedule(models.Model): _name = 'itsulu.blog.schedule' _description = 'Blog Publisher — Schedule Slot' _order = 'slot asc' # ------------------------------------------------------------------ # # Identity # # ------------------------------------------------------------------ # name = fields.Char( string='Slot Name', required=True, default='New Schedule Slot', ) slot = fields.Selection( selection=[ ('morning', 'Morning'), ('afternoon', 'Afternoon'), ('evening', 'Evening'), ], string='Slot', required=True, default='morning', ) active = fields.Boolean( string='Active', default=True, help='Inactive slots are skipped by the scheduler.', ) trigger_time = fields.Float( string='Trigger Time (UTC hours)', default=8.0, help='Hour of day in UTC to trigger generation (e.g. 8.0 = 08:00 UTC).', ) # ------------------------------------------------------------------ # # Content configuration # # ------------------------------------------------------------------ # blog_id = fields.Many2one( comodel_name='blog.blog', string='Target Blog', required=True, help='Blog where generated posts will be created.', ) tone = fields.Char( string='Tone', default='professional and informative', help='Writing tone for this slot. E.g. "technical", "beginner-friendly".', ) auto_publish = fields.Boolean( string='Auto-Publish', default=True, help='Publish the post immediately after generation. ' 'If False, post is saved as draft for manual review.', ) prompt_override = fields.Text( string='User Prompt Override', help='Replaces the global user prompt template for this slot only. ' 'Leave blank to use the global template from Settings.', ) # ------------------------------------------------------------------ # # LLM configuration # # ------------------------------------------------------------------ # llm_provider = fields.Selection( selection=[ ('anthropic', 'Anthropic Claude'), ('openai', 'OpenAI ChatGPT'), ('gemini', 'Google Gemini'), ('ollama', 'Ollama / Open WebUI'), ], string='Text LLM Provider', required=True, default='anthropic', ) llm_model = fields.Char( string='Text LLM Model', default='claude-sonnet-4-20250514', help='Model name passed to the provider API.', ) image_provider = fields.Selection( selection=[ ('none', 'No Image'), ('openai_dalle', 'OpenAI DALL·E 3'), ('google_imagen', 'Google Imagen'), ('stable_diff', 'Stable Diffusion (Ollama)'), ], string='Image Provider', default='none', required=True, help='Image generation provider. Independent from the text LLM.', ) # ------------------------------------------------------------------ # # Social platform toggles (per slot) # # ------------------------------------------------------------------ # platform_twitter = fields.Boolean(string='X/Twitter', default=True) platform_bluesky = fields.Boolean(string='BlueSky', default=True) platform_mastodon = fields.Boolean(string='Mastodon', default=True) platform_linkedin = fields.Boolean(string='LinkedIn', default=True) # ------------------------------------------------------------------ # # Notification # # ------------------------------------------------------------------ # notification_emails = fields.Char( string='Notification Email Override', help='Comma-separated. Overrides the global setting for this slot. ' 'Leave blank to use global Settings.', ) # ------------------------------------------------------------------ # # Stats (computed from related logs) # # ------------------------------------------------------------------ # log_ids = fields.One2many( comodel_name='itsulu.blog.generation.log', inverse_name='schedule_id', string='Generation Logs', ) last_run = fields.Datetime( string='Last Run', compute='_compute_last_run', store=False, ) @api.depends('log_ids.create_date') def _compute_last_run(self): for rec in self: last_log = rec.log_ids.sorted('create_date', reverse=True)[:1] rec.last_run = last_log.create_date if last_log else False # ------------------------------------------------------------------ # # Main orchestration # # ------------------------------------------------------------------ # def run_generation(self, topic: str = None, trigger_source: str = 'scheduled', triggered_by=None, auto_publish: bool = None, platform_overrides: dict = None): """ Full generation pipeline for one schedule slot. Steps: 1. Guard: skip if slot is inactive 2. Resolve topic (argument → queue → LLM-chosen) 3. Create generation log (state='running') 4. Call LLMRouter.generate() 5. Create blog.post with SEO fields and tags 6. Generate cover image (if image_provider != 'none') 7. Create itsulu.blog.post.social 8. Optionally publish the post 9. Update log (state='success') 10. Send notification email :param topic: Force a specific topic (used by wizard). :param trigger_source: 'manual', 'website', or 'scheduled'. :param triggered_by: res.users record of the triggering user. :param auto_publish: Override slot's auto_publish setting. :param platform_overrides: dict {platform: bool} to override slot defaults. :returns: The created blog.post record (or raises UserError on failure). """ self.ensure_one() if not self.active: _logger.info("BlogSchedule.run_generation: slot '%s' is inactive — skipping", self.name) return False _logger.info( "BlogSchedule.run_generation: slot=%s trigger=%s", self.slot, trigger_source ) # Step 2: resolve topic topic_source = 'manual' blog_topic_record = None if not topic: blog_topic_record = self.env['itsulu.blog.topic'].get_next_topic() if blog_topic_record: topic = blog_topic_record.name if blog_topic_record.notes: topic = f"{topic}\n\nAdditional context: {blog_topic_record.notes}" topic_source = 'queue' else: # LLM will choose — we pass a prompt that tells it to pick # a topic relevant to our service portfolio topic = ( 'Choose a timely and relevant topic related to ITSulu\'s services: ' 'AI consulting, IT infrastructure, cloud solutions, cybersecurity, ' 'digital transformation, or enterprise software. ' 'The topic should be useful to IT decision-makers.' ) topic_source = 'llm' # Step 3: create generation log enabled_platforms = self._get_platform_flags(platform_overrides) effective_auto_publish = auto_publish if auto_publish is not None else self.auto_publish log = self.env['itsulu.blog.generation.log'].create({ 'state': 'running', 'trigger_source': trigger_source, 'schedule_slot': self.slot, 'schedule_id': self.id, 'topic_used': topic[:500] if topic else '', 'topic_source': topic_source, 'triggered_by': triggered_by.id if triggered_by else False, 'llm_provider': self.llm_provider, 'llm_model': self.llm_model, 'image_provider': self.image_provider, 'platform_twitter': enabled_platforms.get('twitter_a', True), 'platform_bluesky': enabled_platforms.get('bluesky_a', True), 'platform_mastodon': enabled_platforms.get('mastodon', True), 'platform_linkedin': enabled_platforms.get('linkedin', True), }) self.env.cr.commit() # persist 'running' log before the API call start = time.monotonic() blog_post = None try: # Step 4: call LLM from ..services.llm_router import LLMRouter router = LLMRouter( env=self.env, provider=self.llm_provider, model=self.llm_model, ) target_blog = blog_topic_record.blog_id if blog_topic_record and blog_topic_record.blog_id else self.blog_id llm_response = router.generate( topic=topic, blog_name=target_blog.name or 'ITSulu Insights', tone=(blog_topic_record.tone if blog_topic_record and blog_topic_record.tone else None) or self.tone, enabled_platforms=enabled_platforms, user_prompt=self.prompt_override or None, ) # Step 5: create blog.post blog_post = self._create_blog_post( llm_response=llm_response, blog=target_blog, auto_publish=effective_auto_publish, ) # Step 6: generate cover image if self.image_provider != 'none': self._attach_cover_image(blog_post=blog_post, llm_response=llm_response) # Step 7: create social copy post_url = self._get_post_url(blog_post) self._create_social_record( blog_post=blog_post, llm_response=llm_response, enabled_platforms=enabled_platforms, post_url=post_url, ) # Step 9: update log elapsed = time.monotonic() - start log.write({ 'state': 'success', 'blog_post_id': blog_post.id, 'tokens_used': llm_response.tokens_used, 'duration_seconds': elapsed, 'raw_llm_response': llm_response.raw_text, }) # Mark topic as used if blog_topic_record: blog_topic_record.mark_used(generation_log=log) # Step 10: send notification email log.send_notification_email() _logger.info( "BlogSchedule.run_generation: success — post '%s' tokens=%d duration=%.1fs", blog_post.name, llm_response.tokens_used, elapsed, ) except Exception as exc: elapsed = time.monotonic() - start error_msg = str(exc) _logger.error( "BlogSchedule.run_generation: FAILED after %.1fs — %s", elapsed, error_msg, exc_info=True ) log.write({ 'state': 'error', 'error_message': error_msg, 'duration_seconds': elapsed, }) self.env.cr.commit() raise return blog_post # ------------------------------------------------------------------ # # Private helpers # # ------------------------------------------------------------------ # def _get_platform_flags(self, overrides: dict = None) -> dict: """Merge slot defaults with per-run overrides.""" defaults = { 'twitter_a': self.platform_twitter, 'twitter_b': self.platform_twitter, 'bluesky_a': self.platform_bluesky, 'bluesky_b': self.platform_bluesky, 'mastodon': self.platform_mastodon, 'linkedin': self.platform_linkedin, } if overrides: defaults.update(overrides) return defaults def _create_blog_post(self, llm_response, blog, auto_publish: bool): """Create the blog.post record from the LLM response.""" # Get or create tags tag_ids = self.env['blog.tag'].get_or_create_tags(llm_response.tags) vals = { 'name': llm_response.title, 'blog_id': blog.id, 'body_arch': llm_response.body_html, 'body': llm_response.body_html, 'website_meta_title': llm_response.meta_title, 'website_meta_description': llm_response.meta_description, 'website_meta_keywords': llm_response.meta_keywords, 'tag_ids': [(6, 0, tag_ids.ids)], 'is_published': auto_publish, 'website_published': auto_publish, } post = self.env['blog.post'].create(vals) _logger.info("Created blog.post id=%d name='%s'", post.id, post.name) return post def _attach_cover_image(self, blog_post, llm_response): """Generate and attach a cover image to the blog post.""" try: from ..services.image_router import ImageRouter image_router = ImageRouter(env=self.env, provider=self.image_provider) img_result = image_router.generate_cover( title=llm_response.title, keywords=llm_response.meta_keywords, ) if img_result: blog_post.write({'image_cover': img_result.b64_data}) _logger.info("Cover image attached to post id=%d", blog_post.id) except Exception as exc: # Image generation failure is non-fatal — log and continue _logger.warning( "Cover image generation failed for post id=%d: %s", blog_post.id, exc ) def _create_social_record(self, blog_post, llm_response, enabled_platforms: dict, post_url: str): """Create the itsulu.blog.post.social record and substitute the URL.""" social_vals = { 'blog_post_id': blog_post.id, 'twitter_enabled': enabled_platforms.get('twitter_a', True), 'bluesky_enabled': enabled_platforms.get('bluesky_a', True), 'mastodon_enabled': enabled_platforms.get('mastodon', True), 'linkedin_enabled': enabled_platforms.get('linkedin', True), 'twitter_post_a': llm_response.social.twitter_a if enabled_platforms.get('twitter_a') else '', 'twitter_post_b': llm_response.social.twitter_b if enabled_platforms.get('twitter_b') else '', 'bluesky_post_a': llm_response.social.bluesky_a if enabled_platforms.get('bluesky_a') else '', 'bluesky_post_b': llm_response.social.bluesky_b if enabled_platforms.get('bluesky_b') else '', 'mastodon_post': llm_response.social.mastodon if enabled_platforms.get('mastodon') else '', 'linkedin_post': llm_response.social.linkedin if enabled_platforms.get('linkedin') else '', 'sources_referenced': '\n'.join( f"{s.title} — {s.url}" for s in (llm_response.sources or []) ), } social = self.env['itsulu.blog.post.social'].create(social_vals) social.substitute_url(post_url) return social def _get_post_url(self, blog_post) -> str: """Return the public URL for the blog post.""" base = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url', 'https://itsulu.com' ) try: return blog_post.website_url or f"{base}/blog/{blog_post.blog_id.id}/{blog_post.id}" except Exception: return f"{base}/blog/{blog_post.id}" # ------------------------------------------------------------------ # # Cron entry point (called by ir.cron) # # ------------------------------------------------------------------ # @api.model def _cron_run_all_active_slots(self): """ Called by the daily Odoo cron job. Runs all active schedule slots. Each slot is independent — one failure does not block the others. """ slots = self.search([('active', '=', True)]) for slot in slots: try: slot.run_generation(trigger_source='scheduled') except Exception as exc: _logger.error( "Cron failed for slot '%s': %s", slot.name, exc, exc_info=True ) # Continue with the next slot # ------------------------------------------------------------------ # # Extend blog.tag with get_or_create_tags helper # # ------------------------------------------------------------------ # class BlogTag(models.Model): _inherit = 'blog.tag' @api.model def get_or_create_tags(self, tag_names: list): """ Given a list of tag name strings, return a recordset of blog.tag. Creates tags that do not exist. Case-insensitive matching. """ if not tag_names: return self.browse([]) result_ids = [] for name in tag_names: name = (name or '').strip() if not name: continue existing = self.search([('name', '=ilike', name)], limit=1) if existing: result_ids.append(existing.id) else: new_tag = self.create({'name': name}) result_ids.append(new_tag.id) return self.browse(result_ids) # ------------------------------------------------------------------ # # Extend blog.post with itsulu_social_id for email template access # # ------------------------------------------------------------------ # class BlogPost(models.Model): _inherit = 'blog.post' itsulu_social_id = fields.One2many( comodel_name='itsulu.blog.post.social', inverse_name='blog_post_id', string='Social Media Copy', limit=1, )