# -*- coding: utf-8 -*- """ itsulu.blog.generation.log — Records every generation attempt. Every call to the LLM — whether triggered manually, by the website button, or by a scheduled cron — writes one log record. Operators use this view to monitor token usage, debug failures, and retry failed generations. """ import logging from odoo import api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class BlogGenerationLog(models.Model): _name = 'itsulu.blog.generation.log' _description = 'Blog Publisher — Generation Log' _order = 'create_date desc' _rec_name = 'display_name' # ------------------------------------------------------------------ # # Identity # # ------------------------------------------------------------------ # display_name = fields.Char( string='Name', compute='_compute_display_name', store=True, ) # ------------------------------------------------------------------ # # State # # ------------------------------------------------------------------ # state = fields.Selection( selection=[ ('running', 'Running'), ('success', 'Success'), ('error', 'Error'), ], string='State', default='running', required=True, index=True, ) # ------------------------------------------------------------------ # # Trigger metadata # # ------------------------------------------------------------------ # trigger_source = fields.Selection( selection=[ ('manual', 'Manual (Backend)'), ('website', 'Manual (Website Button)'), ('scheduled', 'Scheduled Cron'), ], string='Trigger', default='manual', required=True, ) schedule_id = fields.Many2one( comodel_name="itsulu.blog.schedule", string="Schedule Record", ondelete="set null", ) schedule_slot = fields.Selection( selection=[ ('morning', 'Morning'), ('afternoon', 'Afternoon'), ('evening', 'Evening'), ], string='Schedule Slot', help='Populated only for scheduled triggers.', ) topic_used = fields.Char(string='Topic Used') topic_source = fields.Selection( selection=[ ('queue', 'Topic Queue'), ('llm', 'LLM-Chosen'), ('manual', 'Manual Input'), ], string='Topic Source', ) triggered_by = fields.Many2one( comodel_name='res.users', string='Triggered By', ondelete='set null', help='The Odoo user who triggered this generation (if manual).', ) # ------------------------------------------------------------------ # # LLM metadata # # ------------------------------------------------------------------ # llm_provider = fields.Char(string='LLM Provider') llm_model = fields.Char(string='LLM Model') image_provider = fields.Char(string='Image Provider') tokens_used = fields.Integer(string='Tokens Used', default=0) duration_seconds = fields.Float(string='Duration (s)', digits=(8, 2)) # ------------------------------------------------------------------ # # Result # # ------------------------------------------------------------------ # blog_post_id = fields.Many2one( comodel_name='blog.post', string='Blog Post', ondelete='set null', ) error_message = fields.Text( string='Error Message', help='Human-readable error from the LLM API or post-processing.', ) raw_llm_response = fields.Text( string='Raw LLM Response', help='Full JSON returned by the LLM — for debugging only.', groups='base.group_system', # only System Administrators can see raw output ) # ------------------------------------------------------------------ # # Enabled social platforms for this run # # ------------------------------------------------------------------ # 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) # ------------------------------------------------------------------ # # Computed # # ------------------------------------------------------------------ # @api.depends('state', 'topic_used', 'llm_provider', 'create_date') def _compute_display_name(self): for rec in self: topic = (rec.topic_used or 'Unknown topic')[:50] provider = rec.llm_provider or '?' state_label = {'running': '⏳', 'success': '✅', 'error': '❌'}.get( rec.state, '' ) rec.display_name = f"{state_label} {topic} [{provider}]" # ------------------------------------------------------------------ # # Actions # # ------------------------------------------------------------------ # def action_retry(self): """ Open the Generate Now wizard pre-filled with the same settings. Only valid for error logs. Returns False for success logs (no-op, prevents accidental duplication). """ self.ensure_one() if self.state != 'error': return False return { 'type': 'ir.actions.act_window', 'name': 'Retry Blog Generation', 'res_model': 'itsulu.blog.generate.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_topic': self.topic_used or '', 'default_llm_provider': self.llm_provider or '', 'default_llm_model': self.llm_model or '', 'default_image_provider': self.image_provider or 'none', 'default_platform_twitter': self.platform_twitter, 'default_platform_bluesky': self.platform_bluesky, 'default_platform_mastodon': self.platform_mastodon, 'default_platform_linkedin': self.platform_linkedin, 'default_retry_log_id': self.id, }, } def send_notification_email(self): """ Send the post-publication notification email. Only sent when blog_post_id.is_published is True. Called automatically by the generation orchestrator after publish. """ self.ensure_one() if not self.blog_post_id: _logger.warning( "send_notification_email called on log %d with no blog_post_id", self.id ) return if not self.blog_post_id.is_published: _logger.info( "Skipping notification email for draft post (log %d)", self.id ) return template = self.env.ref( 'itsulu_blog_publisher.email_template_blog_published', raise_if_not_found=False, ) if not template: _logger.error("Notification email template not found — skipping email") return # Build recipient list: triggering user + fixed addresses fixed_emails = self.env['ir.config_parameter'].sudo().get_param( 'itsulu_blog_publisher.notification_emails', default='' ) recipient_emails = set( e.strip() for e in fixed_emails.split(',') if e.strip() ) if self.triggered_by and self.triggered_by.email: recipient_emails.add(self.triggered_by.email) if not recipient_emails: _logger.warning("No notification email recipients configured — skipping") return for email_addr in recipient_emails: template.with_context( email_to_override=email_addr, ).send_mail( self.id, force_send=True, email_values={'email_to': email_addr}, ) _logger.info( "Notification email sent to %s for post '%s'", ', '.join(recipient_emails), self.blog_post_id.name, )