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>
208 lines
7.4 KiB
Python
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
|