itsulu-blog-publisher/addons/itsulu_blog_publisher/wizards/generate_now_wizard.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

208 lines
7.4 KiB
Python

# -*- coding: utf-8 -*-
"""
itsulu.blog.generate.wizard — On-demand blog generation wizard.
Used by:
- Backend "Generate Now" button (in menu and on schedule records)
- Website toolbar "Generate New Post" button (via JSON-RPC → wizard → redirect)
After action_generate() completes, the user is sent to:
- The blog post form view (backend)
- The published post URL (website)
"""
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class BlogGenerateWizard(models.TransientModel):
_name = 'itsulu.blog.generate.wizard'
_description = 'Blog Publisher — Generate Now Wizard'
# ------------------------------------------------------------------ #
# Fields #
# ------------------------------------------------------------------ #
topic = fields.Char(
string='Topic',
help='Leave blank to use the next topic from the queue, '
'or let the LLM choose based on ITSulu services.',
)
blog_id = fields.Many2one(
comodel_name='blog.blog',
string='Target Blog',
required=True,
)
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',
required=True,
default='claude-sonnet-4-20250514',
)
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,
)
auto_publish = fields.Boolean(
string='Publish Immediately',
default=True,
)
tone = fields.Char(
string='Tone',
default='professional and informative',
)
# Social platform toggles
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)
retry_log_id = fields.Integer(
string='Retry Log ID',
help='Set when opening wizard from a failed log retry action.',
)
# ------------------------------------------------------------------ #
# Defaults #
# ------------------------------------------------------------------ #
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
params = self.env['ir.config_parameter'].sudo()
# Pull defaults from Settings
if 'llm_provider' in fields_list:
res['llm_provider'] = params.get_param(
'itsulu_blog_publisher.default_provider', 'anthropic'
)
if 'llm_model' in fields_list:
res['llm_model'] = params.get_param(
'itsulu_blog_publisher.default_model', 'claude-sonnet-4-20250514'
)
if 'image_provider' in fields_list:
res['image_provider'] = params.get_param(
'itsulu_blog_publisher.default_image_provider', 'none'
)
if 'blog_id' in fields_list:
blog_id_str = params.get_param('itsulu_blog_publisher.default_blog_id', '')
if blog_id_str and blog_id_str.isdigit():
res['blog_id'] = int(blog_id_str)
else:
# Fall back to first available blog
first_blog = self.env['blog.blog'].search([], limit=1)
if first_blog:
res['blog_id'] = first_blog.id
return res
# ------------------------------------------------------------------ #
# Action #
# ------------------------------------------------------------------ #
def action_generate(self):
"""
Validate inputs, create a temporary schedule record (or reuse an existing one
matching this configuration), and delegate to BlogSchedule.run_generation().
Returns an ir.actions.act_window pointing to the created blog.post.
"""
self.ensure_one()
if not self.blog_id:
raise UserError("Please select a Target Blog before generating.")
platform_overrides = {
'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,
}
# Build an ephemeral schedule record for this one-off generation
# (We reuse BlogSchedule.run_generation to keep all orchestration in one place)
ephemeral_slot = self.env['itsulu.blog.schedule'].create({
'name': f'On-Demand: {self.topic or "Queue/LLM"}',
'slot': 'morning', # slot value is irrelevant for manual runs
'active': True,
'blog_id': self.blog_id.id,
'llm_provider': self.llm_provider,
'llm_model': self.llm_model,
'image_provider': self.image_provider,
'auto_publish': self.auto_publish,
'tone': self.tone or 'professional and informative',
'platform_twitter': self.platform_twitter,
'platform_bluesky': self.platform_bluesky,
'platform_mastodon': self.platform_mastodon,
'platform_linkedin': self.platform_linkedin,
})
try:
blog_post = ephemeral_slot.run_generation(
topic=self.topic or None,
trigger_source='manual',
triggered_by=self.env.user,
auto_publish=self.auto_publish,
platform_overrides=platform_overrides,
)
finally:
# Clean up the ephemeral slot — we don't want orphaned records
ephemeral_slot.unlink()
if not blog_post:
raise UserError(
"Blog generation failed. Check the Generation Log for details."
)
# Return action to open the created post
return {
'type': 'ir.actions.act_window',
'name': 'Generated Blog Post',
'res_model': 'blog.post',
'res_id': blog_post.id,
'view_mode': 'form',
'target': 'current',
}
def action_generate_and_open_website(self):
"""
Generate and redirect to the public website URL.
Called from the website toolbar wizard.
"""
result = self.action_generate()
# Switch from backend form to website URL
blog_post = self.env['blog.post'].browse(result.get('res_id'))
if blog_post and blog_post.website_url:
return {
'type': 'ir.actions.act_url',
'url': blog_post.website_url,
'target': 'self',
}
return result