Odoo 14 Environment has no flush_all() (added in Odoo 15+). Use the Odoo 14 global flush via a recordset .flush(). This was raising AttributeError mid-generation, failing ~10 tests + their downstream "no blog.post created" assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
470 lines
18 KiB
Python
470 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
itsulu.blog.schedule — Three configurable daily cron slots.
|
|
|
|
This model owns the full generation orchestration: it calls LLMRouter,
|
|
creates the blog.post, sets SEO fields, creates the social copy record,
|
|
optionally publishes, and sends the notification email.
|
|
|
|
One model — three records (morning, afternoon, evening) configured in
|
|
data/ir_cron_data.xml. Additional slots can be created manually.
|
|
"""
|
|
import logging
|
|
import time
|
|
import base64
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BlogSchedule(models.Model):
|
|
_name = 'itsulu.blog.schedule'
|
|
_description = 'Blog Publisher — Schedule Slot'
|
|
_order = 'slot asc'
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Identity #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
name = fields.Char(
|
|
string='Slot Name',
|
|
required=True,
|
|
default='New Schedule Slot',
|
|
)
|
|
|
|
slot = fields.Selection(
|
|
selection=[
|
|
('morning', 'Morning'),
|
|
('afternoon', 'Afternoon'),
|
|
('evening', 'Evening'),
|
|
],
|
|
string='Slot',
|
|
required=True,
|
|
default='morning',
|
|
)
|
|
|
|
active = fields.Boolean(
|
|
string='Active',
|
|
default=True,
|
|
help='Inactive slots are skipped by the scheduler.',
|
|
)
|
|
|
|
trigger_time = fields.Float(
|
|
string='Trigger Time (UTC hours)',
|
|
default=8.0,
|
|
help='Hour of day in UTC to trigger generation (e.g. 8.0 = 08:00 UTC).',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Content configuration #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_id = fields.Many2one(
|
|
comodel_name='blog.blog',
|
|
string='Target Blog',
|
|
help='Blog where generated posts will be created. Must be set before activating this slot.',
|
|
)
|
|
|
|
tone = fields.Char(
|
|
string='Tone',
|
|
default='professional and informative',
|
|
help='Writing tone for this slot. E.g. "technical", "beginner-friendly".',
|
|
)
|
|
|
|
auto_publish = fields.Boolean(
|
|
string='Auto-Publish',
|
|
default=True,
|
|
help='Publish the post immediately after generation. '
|
|
'If False, post is saved as draft for manual review.',
|
|
)
|
|
|
|
prompt_override = fields.Text(
|
|
string='User Prompt Override',
|
|
help='Replaces the global user prompt template for this slot only. '
|
|
'Leave blank to use the global template from Settings.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# LLM configuration #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
llm_provider = fields.Selection(
|
|
selection=[
|
|
('anthropic', 'Anthropic Claude'),
|
|
('openai', 'OpenAI ChatGPT'),
|
|
('gemini', 'Google Gemini'),
|
|
('ollama', 'Ollama / Open WebUI'),
|
|
],
|
|
string='Text LLM Provider',
|
|
required=True,
|
|
default='anthropic',
|
|
)
|
|
|
|
llm_model = fields.Char(
|
|
string='Text LLM Model',
|
|
default='claude-sonnet-4-20250514',
|
|
help='Model name passed to the provider API.',
|
|
)
|
|
|
|
image_provider = fields.Selection(
|
|
selection=[
|
|
('none', 'No Image'),
|
|
('openai_dalle', 'OpenAI DALL·E 3'),
|
|
('google_imagen', 'Google Imagen'),
|
|
('stable_diff', 'Stable Diffusion (Ollama)'),
|
|
],
|
|
string='Image Provider',
|
|
default='none',
|
|
required=True,
|
|
help='Image generation provider. Independent from the text LLM.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Social platform toggles (per slot) #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
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)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Notification #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
notification_emails = fields.Char(
|
|
string='Notification Email Override',
|
|
help='Comma-separated. Overrides the global setting for this slot. '
|
|
'Leave blank to use global Settings.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Stats (computed from related logs) #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
log_ids = fields.One2many(
|
|
comodel_name='itsulu.blog.generation.log',
|
|
inverse_name='schedule_id',
|
|
string='Generation Logs',
|
|
)
|
|
|
|
last_run = fields.Datetime(
|
|
string='Last Run',
|
|
compute='_compute_last_run',
|
|
store=False,
|
|
)
|
|
|
|
@api.depends('log_ids.create_date')
|
|
def _compute_last_run(self):
|
|
for rec in self:
|
|
last_log = rec.log_ids.sorted('create_date', reverse=True)[:1]
|
|
rec.last_run = last_log.create_date if last_log else False
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Main orchestration #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def run_generation(self, topic: str = None, trigger_source: str = 'scheduled',
|
|
triggered_by=None, auto_publish: bool = None,
|
|
platform_overrides: dict = None):
|
|
"""
|
|
Full generation pipeline for one schedule slot.
|
|
|
|
Steps:
|
|
1. Guard: skip if slot is inactive
|
|
2. Resolve topic (argument → queue → LLM-chosen)
|
|
3. Create generation log (state='running')
|
|
4. Call LLMRouter.generate()
|
|
5. Create blog.post with SEO fields and tags
|
|
6. Generate cover image (if image_provider != 'none')
|
|
7. Create itsulu.blog.post.social
|
|
8. Optionally publish the post
|
|
9. Update log (state='success')
|
|
10. Send notification email
|
|
|
|
:param topic: Force a specific topic (used by wizard).
|
|
:param trigger_source: 'manual', 'website', or 'scheduled'.
|
|
:param triggered_by: res.users record of the triggering user.
|
|
:param auto_publish: Override slot's auto_publish setting.
|
|
:param platform_overrides: dict {platform: bool} to override slot defaults.
|
|
:returns: The created blog.post record (or raises UserError on failure).
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if not self.active:
|
|
_logger.info("BlogSchedule.run_generation: slot '%s' is inactive — skipping", self.name)
|
|
return False
|
|
|
|
_logger.info(
|
|
"BlogSchedule.run_generation: slot=%s trigger=%s",
|
|
self.slot, trigger_source
|
|
)
|
|
|
|
# Step 2: resolve topic
|
|
topic_source = 'manual'
|
|
blog_topic_record = None
|
|
|
|
if not topic:
|
|
blog_topic_record = self.env['itsulu.blog.topic'].get_next_topic()
|
|
if blog_topic_record:
|
|
topic = blog_topic_record.name
|
|
if blog_topic_record.notes:
|
|
topic = f"{topic}\n\nAdditional context: {blog_topic_record.notes}"
|
|
topic_source = 'queue'
|
|
else:
|
|
# LLM will choose — we pass a prompt that tells it to pick
|
|
# a topic relevant to our service portfolio
|
|
topic = (
|
|
'Choose a timely and relevant topic related to ITSulu\'s services: '
|
|
'AI consulting, IT infrastructure, cloud solutions, cybersecurity, '
|
|
'digital transformation, or enterprise software. '
|
|
'The topic should be useful to IT decision-makers.'
|
|
)
|
|
topic_source = 'llm'
|
|
|
|
# Step 3: create generation log
|
|
enabled_platforms = self._get_platform_flags(platform_overrides)
|
|
effective_auto_publish = auto_publish if auto_publish is not None else self.auto_publish
|
|
|
|
log = self.env['itsulu.blog.generation.log'].create({
|
|
'state': 'running',
|
|
'trigger_source': trigger_source,
|
|
'schedule_slot': self.slot,
|
|
'schedule_id': self.id,
|
|
'topic_used': topic[:500] if topic else '',
|
|
'topic_source': topic_source,
|
|
'triggered_by': triggered_by.id if triggered_by else False,
|
|
'llm_provider': self.llm_provider,
|
|
'llm_model': self.llm_model,
|
|
'image_provider': self.image_provider,
|
|
'platform_twitter': enabled_platforms.get('twitter_a', True),
|
|
'platform_bluesky': enabled_platforms.get('bluesky_a', True),
|
|
'platform_mastodon': enabled_platforms.get('mastodon', True),
|
|
'platform_linkedin': enabled_platforms.get('linkedin', True),
|
|
})
|
|
self.env['base'].flush() # persist 'running' log before the API call
|
|
|
|
start = time.monotonic()
|
|
blog_post = None
|
|
|
|
try:
|
|
# Step 4: call LLM
|
|
from ..services.llm_router import LLMRouter
|
|
|
|
router = LLMRouter(
|
|
env=self.env,
|
|
provider=self.llm_provider,
|
|
model=self.llm_model,
|
|
)
|
|
|
|
target_blog = blog_topic_record.blog_id if blog_topic_record and blog_topic_record.blog_id else self.blog_id
|
|
|
|
llm_response = router.generate(
|
|
topic=topic,
|
|
blog_name=target_blog.name or 'ITSulu Insights',
|
|
tone=(blog_topic_record.tone if blog_topic_record and blog_topic_record.tone else None) or self.tone,
|
|
enabled_platforms=enabled_platforms,
|
|
user_prompt=self.prompt_override or None,
|
|
)
|
|
|
|
# Step 5: create blog.post
|
|
blog_post = self._create_blog_post(
|
|
llm_response=llm_response,
|
|
blog=target_blog,
|
|
auto_publish=effective_auto_publish,
|
|
)
|
|
|
|
# Step 6: generate cover image
|
|
if self.image_provider != 'none':
|
|
self._attach_cover_image(blog_post=blog_post, llm_response=llm_response)
|
|
|
|
# Step 7: create social copy
|
|
post_url = self._get_post_url(blog_post)
|
|
self._create_social_record(
|
|
blog_post=blog_post,
|
|
llm_response=llm_response,
|
|
enabled_platforms=enabled_platforms,
|
|
post_url=post_url,
|
|
)
|
|
|
|
# Step 9: update log
|
|
elapsed = time.monotonic() - start
|
|
log.write({
|
|
'state': 'success',
|
|
'blog_post_id': blog_post.id,
|
|
'tokens_used': llm_response.tokens_used,
|
|
'duration_seconds': elapsed,
|
|
'raw_llm_response': llm_response.raw_text,
|
|
})
|
|
|
|
# Mark topic as used
|
|
if blog_topic_record:
|
|
blog_topic_record.mark_used(generation_log=log)
|
|
|
|
# Step 10: send notification email
|
|
log.send_notification_email()
|
|
|
|
_logger.info(
|
|
"BlogSchedule.run_generation: success — post '%s' tokens=%d duration=%.1fs",
|
|
blog_post.name, llm_response.tokens_used, elapsed,
|
|
)
|
|
|
|
except Exception as exc:
|
|
elapsed = time.monotonic() - start
|
|
error_msg = str(exc)
|
|
_logger.error(
|
|
"BlogSchedule.run_generation: FAILED after %.1fs — %s",
|
|
elapsed, error_msg, exc_info=True
|
|
)
|
|
log.write({
|
|
'state': 'error',
|
|
'error_message': error_msg,
|
|
'duration_seconds': elapsed,
|
|
})
|
|
self.env['base'].flush()
|
|
raise
|
|
|
|
return blog_post
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Private helpers #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _get_platform_flags(self, overrides: dict = None) -> dict:
|
|
"""Merge slot defaults with per-run overrides."""
|
|
defaults = {
|
|
'twitter_a': self.platform_twitter,
|
|
'twitter_b': self.platform_twitter,
|
|
'bluesky_a': self.platform_bluesky,
|
|
'bluesky_b': self.platform_bluesky,
|
|
'mastodon': self.platform_mastodon,
|
|
'linkedin': self.platform_linkedin,
|
|
}
|
|
if overrides:
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
def _create_blog_post(self, llm_response, blog, auto_publish: bool):
|
|
"""Create the blog.post record from the LLM response."""
|
|
# Get or create tags
|
|
tag_ids = self.env['blog.tag'].get_or_create_tags(llm_response.tags)
|
|
|
|
vals = {
|
|
'name': llm_response.title,
|
|
'blog_id': blog.id,
|
|
'content': llm_response.body_html,
|
|
'website_meta_title': llm_response.meta_title,
|
|
'website_meta_description': llm_response.meta_description,
|
|
'website_meta_keywords': llm_response.meta_keywords,
|
|
'tag_ids': [(6, 0, tag_ids.ids)],
|
|
'is_published': auto_publish,
|
|
'website_published': auto_publish,
|
|
}
|
|
post = self.env['blog.post'].create(vals)
|
|
_logger.info("Created blog.post id=%d name='%s'", post.id, post.name)
|
|
return post
|
|
|
|
def _attach_cover_image(self, blog_post, llm_response):
|
|
"""Generate and attach a cover image to the blog post."""
|
|
try:
|
|
from ..services.image_router import ImageRouter
|
|
image_router = ImageRouter(env=self.env, provider=self.image_provider)
|
|
img_result = image_router.generate_cover(
|
|
title=llm_response.title,
|
|
keywords=llm_response.meta_keywords,
|
|
)
|
|
if img_result:
|
|
blog_post.write({'image_cover': img_result.b64_data})
|
|
_logger.info("Cover image attached to post id=%d", blog_post.id)
|
|
except Exception as exc:
|
|
# Image generation failure is non-fatal — log and continue
|
|
_logger.warning(
|
|
"Cover image generation failed for post id=%d: %s",
|
|
blog_post.id, exc
|
|
)
|
|
|
|
def _create_social_record(self, blog_post, llm_response, enabled_platforms: dict,
|
|
post_url: str):
|
|
"""Create the itsulu.blog.post.social record and substitute the URL."""
|
|
social_vals = {
|
|
'blog_post_id': blog_post.id,
|
|
'twitter_enabled': enabled_platforms.get('twitter_a', True),
|
|
'bluesky_enabled': enabled_platforms.get('bluesky_a', True),
|
|
'mastodon_enabled': enabled_platforms.get('mastodon', True),
|
|
'linkedin_enabled': enabled_platforms.get('linkedin', True),
|
|
'twitter_post_a': llm_response.social.twitter_a if enabled_platforms.get('twitter_a') else '',
|
|
'twitter_post_b': llm_response.social.twitter_b if enabled_platforms.get('twitter_b') else '',
|
|
'bluesky_post_a': llm_response.social.bluesky_a if enabled_platforms.get('bluesky_a') else '',
|
|
'bluesky_post_b': llm_response.social.bluesky_b if enabled_platforms.get('bluesky_b') else '',
|
|
'mastodon_post': llm_response.social.mastodon if enabled_platforms.get('mastodon') else '',
|
|
'linkedin_post': llm_response.social.linkedin if enabled_platforms.get('linkedin') else '',
|
|
'sources_referenced': '\n'.join(
|
|
f"{s.title} — {s.url}" for s in (llm_response.sources or [])
|
|
),
|
|
}
|
|
social = self.env['itsulu.blog.post.social'].create(social_vals)
|
|
social.substitute_url(post_url)
|
|
return social
|
|
|
|
def _get_post_url(self, blog_post) -> str:
|
|
"""Return the public URL for the blog post."""
|
|
base = self.env['ir.config_parameter'].sudo().get_param(
|
|
'web.base.url', 'https://itsulu.com'
|
|
)
|
|
try:
|
|
return blog_post.website_url or f"{base}/blog/{blog_post.blog_id.id}/{blog_post.id}"
|
|
except Exception:
|
|
return f"{base}/blog/{blog_post.id}"
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Cron entry point (called by ir.cron) #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@api.model
|
|
def _cron_run_all_active_slots(self):
|
|
"""
|
|
Called by the daily Odoo cron job. Runs all active schedule slots.
|
|
Each slot is independent — one failure does not block the others.
|
|
"""
|
|
slots = self.search([('active', '=', True)])
|
|
for slot in slots:
|
|
try:
|
|
slot.run_generation(trigger_source='scheduled')
|
|
except Exception as exc:
|
|
_logger.error(
|
|
"Cron failed for slot '%s': %s", slot.name, exc, exc_info=True
|
|
)
|
|
# Continue with the next slot
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Extend blog.tag with get_or_create_tags helper #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
class BlogTag(models.Model):
|
|
_inherit = 'blog.tag'
|
|
|
|
@api.model
|
|
def get_or_create_tags(self, tag_names: list):
|
|
"""
|
|
Given a list of tag name strings, return a recordset of blog.tag.
|
|
Creates tags that do not exist. Case-insensitive matching.
|
|
"""
|
|
if not tag_names:
|
|
return self.browse([])
|
|
|
|
result_ids = []
|
|
for name in tag_names:
|
|
name = (name or '').strip()
|
|
if not name:
|
|
continue
|
|
existing = self.search([('name', '=ilike', name)], limit=1)
|
|
if existing:
|
|
result_ids.append(existing.id)
|
|
else:
|
|
new_tag = self.create({'name': name})
|
|
result_ids.append(new_tag.id)
|
|
|
|
return self.browse(result_ids)
|