itsulu-blog-publisher/addons/itsulu_blog_publisher/models/blog_generation_log.py
Nicholas Riegel 0fc4febabf Reorganize codebase into Odoo addon structure per ARCHITECTURE.md
Restructure project files to follow the addon layout:
- Move models to addons/itsulu_blog_publisher/models/
- Move services (LLM providers, routers) to addons/itsulu_blog_publisher/services/
- Move wizards to addons/itsulu_blog_publisher/wizards/
- Move views (XML templates) to addons/itsulu_blog_publisher/views/
- Move data (cron, mail templates) to addons/itsulu_blog_publisher/data/
- Move security (ACL) to addons/itsulu_blog_publisher/security/
- Move tests and factories to addons/itsulu_blog_publisher/tests/
- Move BDD features to addons/itsulu_blog_publisher/features/
- Create __init__.py files for all Python packages

This enables proper Odoo module discovery and import structure.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 12:11:42 -04:00

240 lines
8.5 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.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,
)