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:
parent
687b1bfa0f
commit
2fd8d7fa3b
2 changed files with 52 additions and 30 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue