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"> <record id="email_template_blog_published" model="mail.template">
<field name="name">Blog Publisher — Post Published Notification</field> <field name="name">Blog Publisher — Post Published Notification</field>
<field name="model_id" ref="model_itsulu_blog_generation_log"/> <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="subject">[ITSulu Insights] Blog Post Published: {{ object.blog_post_id.name }}</field>
<field name="email_from">${user.email_formatted}</field> <field name="email_from">{{ user.email_formatted }}</field>
<field name="auto_delete">True</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;"> <div style="font-family: Arial, sans-serif; line-height: 1.6; max-width: 700px;">
<h2>Blog Post Published</h2> <h2>Blog Post Published</h2>
<p><strong>Title:</strong> ${object.blog_post_id.name}</p> <p><strong>Title:</strong> <t t-out="object.blog_post_id.name"/></p>
<p><strong>Blog:</strong> ${object.blog_post_id.blog_id.name}</p> <p><strong>Blog:</strong> <t t-out="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>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> <h3>Social Media Posts — Ready to Post</h3>
% for social in object.blog_post_id.itsulu_social_id: <t t-foreach="object.blog_post_id.itsulu_social_id" t-as="social">
% if social.twitter_post_a: <t t-if="social.twitter_post_a">
<h4>Twitter</h4> <h4>Twitter</h4>
<p>${social.twitter_post_a}</p> <p><t t-out="social.twitter_post_a"/></p>
% endif </t>
% if social.twitter_post_b: <t t-if="social.twitter_post_b">
<h4>Twitter</h4> <h4>Twitter</h4>
<p>${social.twitter_post_b}</p> <p><t t-out="social.twitter_post_b"/></p>
% endif </t>
% if social.bluesky_post_a: <t t-if="social.bluesky_post_a">
<h4>BlueSky</h4> <h4>BlueSky</h4>
<p>${social.bluesky_post_a}</p> <p><t t-out="social.bluesky_post_a"/></p>
% endif </t>
% if social.bluesky_post_b: <t t-if="social.bluesky_post_b">
<h4>BlueSky</h4> <h4>BlueSky</h4>
<p>${social.bluesky_post_b}</p> <p><t t-out="social.bluesky_post_b"/></p>
% endif </t>
% if social.mastodon_post: <t t-if="social.mastodon_post">
<h4>Mastodon</h4> <h4>Mastodon</h4>
<p>${social.mastodon_post}</p> <p><t t-out="social.mastodon_post"/></p>
% endif </t>
% if social.linkedin_post: <t t-if="social.linkedin_post">
<h4>LinkedIn</h4> <h4>LinkedIn</h4>
<p>${social.linkedin_post}</p> <p><t t-out="social.linkedin_post"/></p>
% endif </t>
% endfor </t>
</div> </div>
]]></field> ]]></field>
</record> </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') @when('the LLM router is called with a prompt')
def when_llm_router_called(odoo_env, ctx): def when_llm_router_called(odoo_env, ctx):
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter 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 = { provider_map = {
'anthropic': 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider.AnthropicProvider.generate', 'anthropic': 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider.AnthropicProvider.generate',
@ -243,10 +264,11 @@ def when_llm_router_called(odoo_env, ctx):
try: try:
if patch_target: if patch_target:
with patch(patch_target, return_value=mock_resp) as mock_gen: with patch(patch_target, return_value=mock_resp) as mock_gen:
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', '')) # Record the mock before generate() so assertions can see it
result = router.generate(topic='Write a blog post') # even if generate() raises.
ctx['result'] = result
ctx['mock_generate'] = mock_gen 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: else:
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', '')) router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', ''))
ctx['result'] = router.generate(topic='Write a blog post') ctx['result'] = router.generate(topic='Write a blog post')