itsulu-blog-publisher/addons/itsulu_blog_publisher/models/blog_generation_log.py
Nicholas Riegel c039b5f0cb release: v0.4.8 — CI pipeline green + Odoo 17 fixes (squash of !1)
Squash-merge of fix/ci-pipeline-corrections. Drives the full test suite
to 69/69 green on the ITSulu K8s cluster and fixes two production bugs.

Production fixes:
- Email template migrated from dead Odoo Mako (${}/% for) to Odoo 17
  inline_template ({{ }}) + qweb body (type="html", t-out/t-foreach/t-if).
  Notification emails previously rendered raw code in the subject/body.
- _create_blog_post now writes 'content': llm_response.body_html — every
  auto-generated post was publishing empty.
- Removed duplicate itsulu_social_id field (startup warning).

Testing & infra:
- CI pipeline corrected (stage order, DB auth, junit artifact, addons path).
- E2E moved to ephemeral jobs in the itsulu-testing K8s namespace.
- Test code brought up to Odoo 17 (mail rendering, blog.post.content,
  pytest-bdd env fixture, _render_field).

Versioning:
- Introduce MAJOR.MINOR.PATCH scheme, VERSION file, scripts/bump-version.sh,
  CHANGELOG.md; first release v0.4.8. CLAUDE.md §15 documents the process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:58:57 -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,
)