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>
330 lines
14 KiB
Python
330 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Extends res.config.settings with Blog Publisher configuration.
|
|
|
|
All sensitive values (API keys, tokens) are stored in ir.config_parameter
|
|
using Odoo's standard password widget — they are not displayed after save.
|
|
"""
|
|
from odoo import api, fields, models
|
|
|
|
DEFAULT_SYSTEM_PROMPT = (
|
|
"You are an expert technology content writer for a professional AI and IT services company. "
|
|
"You write clear, data-driven, SEO-optimised blog posts. You always cite real, verifiable "
|
|
"sources with working URLs. You respond ONLY with valid JSON — no markdown fences, no "
|
|
"preamble, no explanation outside the JSON object."
|
|
)
|
|
|
|
|
|
class ResConfigSettings(models.TransientModel):
|
|
_inherit = 'res.config.settings'
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# LLM — Text providers #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_pub_default_provider = fields.Selection(
|
|
selection=[
|
|
('anthropic', 'Anthropic Claude'),
|
|
('openai', 'OpenAI ChatGPT'),
|
|
('gemini', 'Google Gemini'),
|
|
('ollama', 'Ollama / Open WebUI'),
|
|
],
|
|
string='Default Text LLM Provider',
|
|
config_parameter='itsulu_blog_publisher.default_provider',
|
|
)
|
|
|
|
blog_pub_default_model = fields.Char(
|
|
string='Default Text LLM Model',
|
|
config_parameter='itsulu_blog_publisher.default_model',
|
|
default='claude-sonnet-4-20250514',
|
|
)
|
|
|
|
# Anthropic
|
|
blog_pub_anthropic_api_key = fields.Char(
|
|
string='Anthropic API Key (or Pro account token)',
|
|
config_parameter='itsulu_blog_publisher.anthropic_api_key',
|
|
help='Starts with sk-ant-api03- (API key) or claude.ai/api personal token. '
|
|
'Never shared outside this server.',
|
|
)
|
|
|
|
blog_pub_anthropic_model = fields.Selection(
|
|
selection=[
|
|
('claude-sonnet-4-20250514', 'Claude Sonnet 4 (Recommended)'),
|
|
('claude-opus-4-20250514', 'Claude Opus 4 (Best quality, slower)'),
|
|
('claude-haiku-4-5-20251001','Claude Haiku 4.5 (Fastest, lowest cost)'),
|
|
('claude-3-5-sonnet-20241022','Claude 3.5 Sonnet (Legacy)'),
|
|
('claude-3-opus-20240229', 'Claude 3 Opus (Legacy)'),
|
|
],
|
|
string='Anthropic Default Model',
|
|
config_parameter='itsulu_blog_publisher.anthropic_default_model',
|
|
default='claude-sonnet-4-20250514',
|
|
)
|
|
|
|
# OpenAI
|
|
blog_pub_openai_api_key = fields.Char(
|
|
string='OpenAI API Key',
|
|
config_parameter='itsulu_blog_publisher.openai_api_key',
|
|
help='Starts with sk-. Used for GPT-4o text generation and DALL·E 3 images.',
|
|
)
|
|
|
|
blog_pub_openai_model = fields.Selection(
|
|
selection=[
|
|
('gpt-4o', 'GPT-4o (Recommended)'),
|
|
('gpt-4o-mini', 'GPT-4o Mini (Faster)'),
|
|
('o4-mini', 'o4-mini'),
|
|
('gpt-4-turbo', 'GPT-4 Turbo (Legacy)'),
|
|
],
|
|
string='OpenAI Default Model',
|
|
config_parameter='itsulu_blog_publisher.openai_default_model',
|
|
default='gpt-4o',
|
|
)
|
|
|
|
# Google Gemini
|
|
blog_pub_gemini_api_key = fields.Char(
|
|
string='Google Gemini API Key',
|
|
config_parameter='itsulu_blog_publisher.gemini_api_key',
|
|
)
|
|
|
|
blog_pub_gemini_model = fields.Selection(
|
|
selection=[
|
|
('gemini-2.0-flash', 'Gemini 2.0 Flash (Recommended)'),
|
|
('gemini-2.5-pro', 'Gemini 2.5 Pro (Best quality)'),
|
|
('gemini-2.5-flash', 'Gemini 2.5 Flash'),
|
|
('gemini-1.5-pro', 'Gemini 1.5 Pro (Legacy)'),
|
|
('gemini-1.5-flash', 'Gemini 1.5 Flash (Legacy)'),
|
|
],
|
|
string='Gemini Default Model',
|
|
config_parameter='itsulu_blog_publisher.gemini_default_model',
|
|
default='gemini-2.0-flash',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Ollama / Open WebUI #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_pub_ollama_base_url = fields.Char(
|
|
string='Ollama / Open WebUI Base URL',
|
|
config_parameter='itsulu_blog_publisher.ollama_base_url',
|
|
help=(
|
|
'Examples:\n'
|
|
' Local Ollama: http://localhost:11434\n'
|
|
' Self-hosted Ollama: http://192.168.1.100:11434\n'
|
|
' Open WebUI (local): http://localhost:3000\n'
|
|
' Open WebUI (server): https://openwebui.yourcompany.com'
|
|
),
|
|
)
|
|
|
|
blog_pub_ollama_model = fields.Char(
|
|
string='Ollama Default Model',
|
|
config_parameter='itsulu_blog_publisher.ollama_default_model',
|
|
default='mistral',
|
|
help='Must be pulled in Ollama first (ollama pull mistral).',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Image generation #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_pub_default_image_provider = fields.Selection(
|
|
selection=[
|
|
('none', 'No Image Generation'),
|
|
('openai_dalle', 'OpenAI DALL·E 3'),
|
|
('google_imagen', 'Google Imagen'),
|
|
('stable_diff', 'Stable Diffusion (Ollama)'),
|
|
],
|
|
string='Default Image Provider',
|
|
config_parameter='itsulu_blog_publisher.default_image_provider',
|
|
default='none',
|
|
)
|
|
|
|
blog_pub_image_ollama_model = fields.Char(
|
|
string='Ollama Image Model',
|
|
config_parameter='itsulu_blog_publisher.image_ollama_model',
|
|
default='stable-diffusion',
|
|
help='Ollama model to use for image generation (e.g. stable-diffusion).',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Default blog & content #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_pub_default_blog_id = fields.Many2one(
|
|
comodel_name='blog.blog',
|
|
string='Default Blog',
|
|
compute='_compute_default_blog_id',
|
|
inverse='_set_default_blog_id',
|
|
help='Default blog for "Generate Now" when no slot is specified.',
|
|
)
|
|
|
|
@api.depends()
|
|
def _compute_default_blog_id(self):
|
|
param = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.default_blog_id'
|
|
)
|
|
blog_id = int(param) if param and param.isdigit() else False
|
|
for rec in self:
|
|
rec.blog_pub_default_blog_id = blog_id
|
|
|
|
def _set_default_blog_id(self):
|
|
for rec in self:
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.default_blog_id',
|
|
str(rec.blog_pub_default_blog_id.id) if rec.blog_pub_default_blog_id else '',
|
|
)
|
|
|
|
blog_pub_default_tone = fields.Char(
|
|
string='Default Tone',
|
|
config_parameter='itsulu_blog_publisher.default_tone',
|
|
default='professional and informative',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Prompt templates #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_pub_system_prompt = fields.Text(
|
|
string='System Prompt',
|
|
compute='_compute_system_prompt',
|
|
inverse='_set_system_prompt',
|
|
help='Sent to the LLM as the system/persona instruction. '
|
|
'Edit to tune writing style, persona, and output format.',
|
|
)
|
|
|
|
@api.depends()
|
|
def _compute_system_prompt(self):
|
|
param = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.system_prompt', DEFAULT_SYSTEM_PROMPT
|
|
)
|
|
for rec in self:
|
|
rec.blog_pub_system_prompt = param or DEFAULT_SYSTEM_PROMPT
|
|
|
|
def _set_system_prompt(self):
|
|
for rec in self:
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.system_prompt',
|
|
rec.blog_pub_system_prompt or '',
|
|
)
|
|
|
|
blog_pub_user_prompt_template = fields.Text(
|
|
string='User Prompt Template',
|
|
compute='_compute_user_prompt_template',
|
|
inverse='_set_user_prompt_template',
|
|
help='Template for the user message. Available variables: '
|
|
'{topic}, {blog_name}, {date}, {tone}, {enabled_platforms}.',
|
|
)
|
|
|
|
@api.depends()
|
|
def _compute_user_prompt_template(self):
|
|
from ..services.llm_router import DEFAULT_USER_PROMPT_TEMPLATE
|
|
param = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.user_prompt_template', ''
|
|
)
|
|
for rec in self:
|
|
rec.blog_pub_user_prompt_template = param or DEFAULT_USER_PROMPT_TEMPLATE
|
|
|
|
def _set_user_prompt_template(self):
|
|
for rec in self:
|
|
self.env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.user_prompt_template',
|
|
rec.blog_pub_user_prompt_template or '',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Notification email #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
blog_pub_notification_emails = fields.Char(
|
|
string='Notification Email Recipients',
|
|
config_parameter='itsulu_blog_publisher.notification_emails',
|
|
default='nicholasr@itsulu.com,sales@itsulu.com',
|
|
help='Comma-separated. The user who triggers generation is always added automatically.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Test connection actions #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def action_test_anthropic_connection(self):
|
|
"""Test the Anthropic API key with a minimal call."""
|
|
self._test_llm_connection('anthropic', self.blog_pub_anthropic_model or 'claude-haiku-4-5-20251001')
|
|
|
|
def action_test_openai_connection(self):
|
|
self._test_llm_connection('openai', self.blog_pub_openai_model or 'gpt-4o-mini')
|
|
|
|
def action_test_gemini_connection(self):
|
|
self._test_llm_connection('gemini', self.blog_pub_gemini_model or 'gemini-2.0-flash')
|
|
|
|
def action_test_ollama_connection(self):
|
|
self._test_llm_connection('ollama', self.blog_pub_ollama_model or 'mistral')
|
|
|
|
def _test_llm_connection(self, provider: str, model: str):
|
|
from ..services.llm_router import LLMRouter
|
|
try:
|
|
router = LLMRouter(self.env, provider=provider, model=model)
|
|
# Minimal test prompt — won't produce valid blog JSON but will confirm connectivity
|
|
raw, tokens = self.env['ir.config_parameter'] # just to keep the pattern consistent
|
|
# Actually call the provider directly for a small test
|
|
raw, tokens = self._minimal_provider_test(provider, model)
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': 'Connection Successful',
|
|
'message': f'{provider.title()} API responded. Tokens used: {tokens}.',
|
|
'type': 'success',
|
|
},
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': 'Connection Failed',
|
|
'message': str(exc),
|
|
'type': 'danger',
|
|
},
|
|
}
|
|
|
|
def _minimal_provider_test(self, provider: str, model: str):
|
|
"""Send a tiny prompt to verify API key and connectivity."""
|
|
if provider == 'anthropic':
|
|
from ..services.anthropic_provider import AnthropicProvider
|
|
key = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', ''
|
|
)
|
|
p = AnthropicProvider(api_key=key, model=model)
|
|
return p.generate(
|
|
system_prompt='You are a test assistant.',
|
|
user_prompt='Reply with exactly: {"test": "ok"}'
|
|
)
|
|
elif provider == 'openai':
|
|
from ..services.openai_provider import OpenAIProvider
|
|
key = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.openai_api_key', ''
|
|
)
|
|
p = OpenAIProvider(api_key=key, model=model)
|
|
return p.generate(
|
|
system_prompt='You are a test assistant.',
|
|
user_prompt='Reply with exactly: {"test": "ok"}'
|
|
)
|
|
elif provider == 'gemini':
|
|
from ..services.gemini_provider import GeminiProvider
|
|
key = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.gemini_api_key', ''
|
|
)
|
|
p = GeminiProvider(api_key=key, model=model)
|
|
return p.generate(
|
|
system_prompt='You are a test assistant.',
|
|
user_prompt='Reply with exactly: {"test": "ok"}'
|
|
)
|
|
elif provider == 'ollama':
|
|
from ..services.ollama_provider import OllamaProvider
|
|
base_url = self.env['ir.config_parameter'].sudo().get_param(
|
|
'itsulu_blog_publisher.ollama_base_url', ''
|
|
)
|
|
p = OllamaProvider(base_url=base_url, model=model)
|
|
return p.generate(
|
|
system_prompt='You are a test assistant.',
|
|
user_prompt='Reply with exactly: {"test": "ok"}'
|
|
)
|
|
raise UserError(f"Unknown provider: {provider}")
|