itsulu-blog-publisher/addons/itsulu_blog_publisher/models/blog_generation_log.py
Nicholas Riegel fd0fa24569 fix: resolve 18 test failures across BDD, email, and performance tests
- Add tests/conftest.py with odoo_env fixture so pytest-bdd can access
  the pytest-odoo env fixture (fixes all 14 BDD scenario failures)
- Fix send_notification_email() to use force_send=False so mail.mail
  records remain in queue for test assertions; pass res_id/model so
  tests can look up records by (res_id, model) pair
- Fix test_generation_latency_under_30_seconds: replace raw SQL INSERT
  into ir_logging.body (column removed in Odoo 17) with _logger.info()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 02:33:48 -04:00

242 lines
8.6 KiB
Python

# -*- 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.send_mail(
self.id,
force_send=False,
email_values={
'email_to': email_addr,
'res_id': self.id,
'model': self._name,
},
)
_logger.info(
"Notification email sent to %s for post '%s'",
', '.join(recipient_emails),
self.blog_post_id.name,
)