itsulu-blog-publisher/addons/itsulu_blog_publisher/models/res_config_settings.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

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}")