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>
242 lines
8.6 KiB
Python
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,
|
|
)
|