fix: migrate email template to Odoo 17 syntax; fix BDD LLM mock JSON

PRODUCTION BUG: email template used pre-Odoo-14 Mako syntax (${} and
% for/% if), which Odoo 17 does not render — real notification emails
would show literal '${object.blog_post_id.name}' in the subject.
- subject/email_from: ${...} → {{ ... }} (inline_template engine)
- body_html: add type="qweb"; ${x} → <t t-out="x"/>;
  % for → <t t-foreach t-as>; % if → <t t-if>

BDD: when_llm_router_called mocked provider.generate() returning raw
HTML in .text, but LLMRouter._parse_response expects JSON and raised
UserError before ctx['mock_generate'] was set. Now returns valid JSON
with all required fields, and records the mock before generate() runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Riegel 2026-05-30 02:57:10 -04:00
parent 687b1bfa0f
commit 2fd8d7fa3b
2 changed files with 52 additions and 30 deletions

View file

@ -5,42 +5,42 @@
<record id="email_template_blog_published" model="mail.template">
<field name="name">Blog Publisher — Post Published Notification</field>
<field name="model_id" ref="model_itsulu_blog_generation_log"/>
<field name="subject">[ITSulu Insights] Blog Post Published: ${object.blog_post_id.name}</field>
<field name="email_from">${user.email_formatted}</field>
<field name="subject">[ITSulu Insights] Blog Post Published: {{ object.blog_post_id.name }}</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="auto_delete">True</field>
<field name="body_html"><![CDATA[
<field name="body_html" type="qweb"><![CDATA[
<div style="font-family: Arial, sans-serif; line-height: 1.6; max-width: 700px;">
<h2>Blog Post Published</h2>
<p><strong>Title:</strong> ${object.blog_post_id.name}</p>
<p><strong>Blog:</strong> ${object.blog_post_id.blog_id.name}</p>
<p><strong>URL:</strong> <a href="https://itsulu.com${object.blog_post_id.website_url}">https://itsulu.com${object.blog_post_id.website_url}</a></p>
<p><strong>Title:</strong> <t t-out="object.blog_post_id.name"/></p>
<p><strong>Blog:</strong> <t t-out="object.blog_post_id.blog_id.name"/></p>
<p><strong>URL:</strong> <a t-attf-href="https://itsulu.com{{ object.blog_post_id.website_url }}">https://itsulu.com<t t-out="object.blog_post_id.website_url"/></a></p>
<h3>Social Media Posts — Ready to Post</h3>
% for social in object.blog_post_id.itsulu_social_id:
% if social.twitter_post_a:
<t t-foreach="object.blog_post_id.itsulu_social_id" t-as="social">
<t t-if="social.twitter_post_a">
<h4>Twitter</h4>
<p>${social.twitter_post_a}</p>
% endif
% if social.twitter_post_b:
<p><t t-out="social.twitter_post_a"/></p>
</t>
<t t-if="social.twitter_post_b">
<h4>Twitter</h4>
<p>${social.twitter_post_b}</p>
% endif
% if social.bluesky_post_a:
<p><t t-out="social.twitter_post_b"/></p>
</t>
<t t-if="social.bluesky_post_a">
<h4>BlueSky</h4>
<p>${social.bluesky_post_a}</p>
% endif
% if social.bluesky_post_b:
<p><t t-out="social.bluesky_post_a"/></p>
</t>
<t t-if="social.bluesky_post_b">
<h4>BlueSky</h4>
<p>${social.bluesky_post_b}</p>
% endif
% if social.mastodon_post:
<p><t t-out="social.bluesky_post_b"/></p>
</t>
<t t-if="social.mastodon_post">
<h4>Mastodon</h4>
<p>${social.mastodon_post}</p>
% endif
% if social.linkedin_post:
<p><t t-out="social.mastodon_post"/></p>
</t>
<t t-if="social.linkedin_post">
<h4>LinkedIn</h4>
<p>${social.linkedin_post}</p>
% endif
% endfor
<p><t t-out="social.linkedin_post"/></p>
</t>
</t>
</div>
]]></field>
</record>

View file

@ -226,10 +226,31 @@ def given_ollama_base_url(odoo_env, url):
)
def _valid_llm_json():
"""Return a JSON string with all fields LLMRouter._parse_response requires."""
import json
return json.dumps({
'title': 'Generated Blog Post',
'body_html': '<p>' + ('Generated content. ' * 30) + '</p>',
'meta_title': 'Generated SEO Title',
'meta_description': 'Generated meta description for the post.',
'meta_keywords': 'ai, testing, blog',
'tags': ['AI', 'Testing'],
'social': {
'twitter_a': 'Tweet A', 'twitter_b': 'Tweet B',
'bluesky_a': 'BlueSky A', 'bluesky_b': 'BlueSky B',
'mastodon': 'Mastodon post', 'linkedin': 'LinkedIn post',
},
'sources': [],
})
@when('the LLM router is called with a prompt')
def when_llm_router_called(odoo_env, ctx):
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
mock_resp = MagicMock(text='<p>Generated</p>', tokens_used=500)
# The router parses provider_response.text as JSON, so the mock must
# return a valid JSON blob with all required fields — not raw HTML.
mock_resp = MagicMock(text=_valid_llm_json(), tokens_used=500)
provider_map = {
'anthropic': 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider.AnthropicProvider.generate',
@ -243,10 +264,11 @@ def when_llm_router_called(odoo_env, ctx):
try:
if patch_target:
with patch(patch_target, return_value=mock_resp) as mock_gen:
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', ''))
result = router.generate(topic='Write a blog post')
ctx['result'] = result
# Record the mock before generate() so assertions can see it
# even if generate() raises.
ctx['mock_generate'] = mock_gen
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', ''))
ctx['result'] = router.generate(topic='Write a blog post')
else:
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', ''))
ctx['result'] = router.generate(topic='Write a blog post')