Compare commits

..

1 commit

Author SHA1 Message Date
6702c8390e release: v0.5.0 — establish Odoo 19.0 branch (multi-version support)
Seed the 19.0 series branch from the 17.0 baseline and wire up the
per-Odoo-version structure:
- .odoo-series = 19.0; Dockerfile FROM odoo:19.0; manifest 19.0.0.5.0
- PORTING.md tracks the Odoo-19 API porting checklist
- README retargeted to 19.0 with a porting-in-progress notice
- CHANGELOG v0.5.0 entry

NOTE: code is seeded from 17.0 — the Odoo-19 port is NOT yet verified
(see PORTING.md). Not deploy-ready on a 19.0 instance until the suite
runs green there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:15:50 -04:00
18 changed files with 171 additions and 192 deletions

View file

@ -1 +1 @@
14.0
19.0

View file

@ -12,24 +12,6 @@ Release notes are written in plain language so anyone on the team can follow wha
---
## v0.5.1 — 2026-05-30 (14.0)
The **Odoo 14.0 port is complete and verified** — the full automated suite (69 tests) passes
on a real Odoo 14.0 instance and the module installs cleanly. This branch is ready for the
ITSulu production instance.
### Changed (Odoo 14 compatibility)
- Email template moved back to Odoo 14's Jinja2 syntax.
- Admin views adjusted to Odoo 14's conditional-visibility format (this had been blocking the
module from installing on 14).
- Test suite updated for Odoo 14's test framework and database APIs.
- Performance query budget tuned for Odoo 14 (it issues a few more framework queries than 17).
See [PORTING.md](PORTING.md) for the full list of Odoo-14 deltas resolved.
---
## v0.5.0 — 2026-05-30
Sets up support for **multiple Odoo Community versions**, each on its own branch — the same
@ -48,9 +30,8 @@ model the Odoo project itself uses. We initially target **Odoo 19.0** (the lates
### Notes
- This `14.0` branch is the target for the **ITSulu production** instance. It is **seeded from
the 17.0 codebase**, so the Odoo-14 port is still being completed — most notably the email
template must move back to Odoo 14's older Jinja syntax. See `PORTING.md`.
- This `19.0` branch is **seeded from the 17.0 codebase** and the Odoo-19 port is still being
verified — see `PORTING.md`. For production today, use the `14.0` branch (also in progress).
- Documentation (`README`, `CLAUDE.md` §15) now describes the branch-per-version model.
---

View file

@ -1,14 +1,12 @@
FROM odoo:14.0
FROM odoo:19.0
# Install Python testing dependencies. odoo:14.0 ships Python 3.7, so pin to
# the last releases that still support 3.7 (pytest 8 / pytest-bdd 7 / pytest-html 4
# all require 3.8+).
# Install Python testing dependencies using the system Python
RUN python3 -m pip install --no-cache-dir \
"pytest>=7,<8" \
"pytest-odoo<2" \
"pytest-bdd>=6,<7" \
"pytest-cov<5" \
"pytest-html<4" \
pytest \
pytest-odoo \
pytest-bdd \
pytest-cov \
pytest-html \
requests
# Copy addon to Odoo addons path

View file

@ -1,41 +1,35 @@
# Porting status — Odoo 14.0 branch
# Porting status — Odoo 19.0 branch
**✅ PORT COMPLETE & VERIFIED (v0.5.1)** — full suite **69/69 passing** on a live Odoo 14.0
instance (K8s `odoo_test_14`, base image `odoo:14.0`, Python 3.7). Module installs cleanly.
This branch targets **Odoo Community 19.0**. It was **seeded from the 17.0 baseline**
(the `main` line) and the Odoo-19-specific porting is tracked here. Until every item below
is verified, treat this branch as **work in progress** — do not deploy to a 19.0 instance
expecting it to work unverified.
This branch targets **Odoo Community 14.0** — the version **ITSulu runs in production**. It was
seeded from the 17.0 baseline and ported to Odoo 14 APIs.
> The product feature set is the same across all Odoo branches; only the Odoo-API-specific code
> differs. Odoo 14 is **older** than the 17.0 baseline, so several things move *backwards*.
> See `CLAUDE.md` §15 for the branch model and §11.5 / §12 for version-specific gotchas.
> The product feature set is the same across all Odoo branches; only the Odoo-API-specific
> code differs. See `CLAUDE.md` §15 for the branch model and §11.5 / §12 for known
> version-specific gotchas.
## Series markers (done)
- [x] `.odoo-series` = `14.0`
- [x] `Dockerfile` base image = `odoo:14.0`
- [x] manifest version prefix = `14.0.` (via `bump-version.sh`)
- [x] `.odoo-series` = `19.0`
- [x] `Dockerfile` base image = `odoo:19.0`
- [x] manifest version prefix = `19.0.` (via `bump-version.sh`)
## Odoo 14 deltas resolved (what the port required)
## API porting checklist (verify on a real Odoo 19.0 instance)
- [x] **Mail template → Jinja2.** `data/mail_template_data.xml` reverted from the 17.0 qweb
(`type="html"`, `<t t-out>`) to Odoo 14 Jinja2 (`${...}`, `% if`, `% for`).
- [x] **View conditional attrs → `attrs`.** 7 Odoo-17 bare/expression `invisible="…"`
attributes (blog_topic, blog_generation_log, blog_schedule web_ribbon) converted to
Odoo 14 `attrs="{'invisible': [domain]}"`. This was blocking module install.
- [x] **Test framework: `setUpClass`/`cls.env` → `setUp`/`self.env`.** Odoo 14 exposes the env
in `setUp` (instance), not `setUpClass` (Odoo 15+). 13 blocks across 6 files converted.
- [x] **`env.flush_all()``env['base'].flush()`.** `flush_all` is Odoo 15+; 14 uses
recordset `.flush()`.
- [x] **Test mocks set `body_html`.** Odoo 14 rejects an unset MagicMock written to
`blog.post.content` (`can't adapt type 'MagicMock'`); 17 silently stringified it.
- [x] **Query budget.** Odoo 14 issues ~54 framework queries vs 17's <50; budget raised to 60.
- [x] Python 3.7 syntax verified (compileall); pytest 7.4.4 / pytest-bdd 6.1.1 / pytest-odoo
1.0.1 pinned for Python 3.7.
- [x] **Full suite green: 69/69 on odoo:14.0.**
- [ ] Module installs cleanly: `odoo -i itsulu_blog_publisher` on 19.0
- [ ] `blog.post` body field name (was `content` in 17.0 — confirm for 19.0)
- [ ] `mail.template` rendering (subject inline_template, body qweb `type="html"`)
- [ ] `mail.template._render_field` signature/return shape
- [ ] `website_blog` dependency + view inheritance still valid
- [ ] Wizard / settings views pass RELAXNG validation on 19.0
- [ ] `ir.cron` data format
- [ ] Python version compatibility (19.0 ships on a newer Python)
- [ ] Full test suite green on a 19.0 template DB (K8s job, §8)
## Releasing further changes on this branch
## How to work this branch
1. Use the 14.0 K8s job (base image `odoo:14.0`, fresh `odoo_test_14`, `odoo -i` then pytest).
2. Record any new Odoo-14 gotcha in `CLAUDE.md` §12.
3. `bump-version.sh patch|minor` → CHANGELOG → commit → `bump-version.sh tag` (tags `14.0-vX.Y.Z`).
1. Stand up a 19.0 template DB (mirror §8, base image `odoo:19.0`).
2. Run the suite, fix failures one Odoo-API difference at a time.
3. Record each gotcha in `CLAUDE.md` §12 tagged with the series.
4. When green, cut a release on this branch (`bump-version.sh` → tag `19.0-vX.Y.Z`).

View file

@ -1,12 +1,12 @@
# ITSulu Blog Publisher — Odoo 14 Addon
# ITSulu Blog Publisher — Odoo 19 Addon
**Version:** 0.5.1
**Version:** 0.5.0 · **Odoo series:** 19.0
Automated blog post generation and publishing for Odoo 14.0 Community Edition, powered by generative AI (Anthropic Claude, OpenAI, Google Gemini, or Ollama).
Automated blog post generation and publishing for Odoo 19.0 Community Edition, powered by generative AI (Anthropic Claude, OpenAI, Google Gemini, or Ollama).
> ⚠️ **Porting in progress.** This `14.0` branch is the target for the ITSulu production
> instance. It is seeded from the 17.0 baseline; the Odoo-14 API port (notably the older
> Jinja-style mail templates) is tracked in [PORTING.md](PORTING.md).
> ⚠️ **Porting in progress.** This `19.0` branch is seeded from the 17.0 baseline; the
> Odoo-19 API port is tracked in [PORTING.md](PORTING.md). Use the `14.0` branch for the
> ITSulu production instance until 19.0 is verified.
> **Versioning**`MAJOR.MINOR.PATCH` (each 0999). MAJOR = major release for sale / significant change; MINOR = features or performance improvements; PATCH = a single group of commits. See [CHANGELOG.md](CHANGELOG.md) for release notes and [CLAUDE.md](CLAUDE.md) §15 for the full scheme.
@ -67,9 +67,9 @@ primed PostgreSQL template database. See [CLAUDE.md](CLAUDE.md) §8 for the K8s
### Prerequisites
- Odoo 14.0 Community
- Python 3.63.8
- PostgreSQL 10+
- Odoo 19.0 Community
- Python 3.11+
- PostgreSQL 13+
- pip packages: `requests`, `pytest-odoo`, `pytest-bdd`
### Steps

View file

@ -1 +1 @@
0.5.1
0.5.0

View file

@ -3,7 +3,7 @@
'name': 'ITSulu Blog Publisher',
# Odoo manifest version = <odoo_series>.<product_version>. Product version
# is tracked in the repo-root VERSION file (currently 0.4.8). See CLAUDE.md §15.
'version': '14.0.0.5.1',
'version': '19.0.0.5.0',
'summary': 'AI-powered blog post generation with multi-LLM support, scheduling, and social media copy',
'description': """
ITSulu Blog Publisher

View file

@ -2,48 +2,47 @@
<odoo>
<data noupdate="0">
<!-- Odoo 14 renders mail.template with Jinja2 (${...}, % if, % for) -->
<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[
<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>
<h3>Social Media Posts — Ready to Post</h3>
% for social in object.blog_post_id.itsulu_social_id:
% if social.twitter_post_a:
<h4>Twitter</h4>
<p>${social.twitter_post_a}</p>
% endif
% if social.twitter_post_b:
<h4>Twitter</h4>
<p>${social.twitter_post_b}</p>
% endif
% if social.bluesky_post_a:
<h4>BlueSky</h4>
<p>${social.bluesky_post_a}</p>
% endif
% if social.bluesky_post_b:
<h4>BlueSky</h4>
<p>${social.bluesky_post_b}</p>
% endif
% if social.mastodon_post:
<h4>Mastodon</h4>
<p>${social.mastodon_post}</p>
% endif
% if social.linkedin_post:
<h4>LinkedIn</h4>
<p>${social.linkedin_post}</p>
% endif
% endfor
</div>
]]></field>
<field name="body_html" type="html">
<div style="font-family: Arial, sans-serif; line-height: 1.6; max-width: 700px;">
<h2>Blog Post Published</h2>
<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>
<t t-foreach="object.blog_post_id.itsulu_social_id" t-as="social">
<t t-if="social.twitter_post_a">
<h4>Twitter</h4>
<p><t t-out="social.twitter_post_a"/></p>
</t>
<t t-if="social.twitter_post_b">
<h4>Twitter</h4>
<p><t t-out="social.twitter_post_b"/></p>
</t>
<t t-if="social.bluesky_post_a">
<h4>BlueSky</h4>
<p><t t-out="social.bluesky_post_a"/></p>
</t>
<t t-if="social.bluesky_post_b">
<h4>BlueSky</h4>
<p><t t-out="social.bluesky_post_b"/></p>
</t>
<t t-if="social.mastodon_post">
<h4>Mastodon</h4>
<p><t t-out="social.mastodon_post"/></p>
</t>
<t t-if="social.linkedin_post">
<h4>LinkedIn</h4>
<p><t t-out="social.linkedin_post"/></p>
</t>
</t>
</div>
</field>
</record>
</data>

View file

@ -244,7 +244,7 @@ class BlogSchedule(models.Model):
'platform_mastodon': enabled_platforms.get('mastodon', True),
'platform_linkedin': enabled_platforms.get('linkedin', True),
})
self.env['base'].flush() # persist 'running' log before the API call
self.env.flush_all() # persist 'running' log before the API call
start = time.monotonic()
blog_post = None
@ -323,7 +323,7 @@ class BlogSchedule(models.Model):
'error_message': error_msg,
'duration_seconds': elapsed,
})
self.env['base'].flush()
self.env.flush_all()
raise
return blog_post

View file

@ -14,10 +14,11 @@ from .factories import BlogPublisherFactory
class TestBlogGenerationLogCreation(TransactionCase):
"""Verify that generation log records capture the correct metadata."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_successful_log_record_is_created_with_correct_fields(self):
"""
@ -104,9 +105,10 @@ class TestBlogGenerationLogCreation(TransactionCase):
class TestBlogGenerationLogRetry(TransactionCase):
"""Verify that failed logs expose a working Retry action."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
def test_error_log_action_retry_returns_wizard_action(self):
"""

View file

@ -16,10 +16,11 @@ from .factories import BlogPublisherFactory
class TestSEOPopulation(TransactionCase):
"""Verify that all SEO fields are correctly populated after blog post generation."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_generated_post_has_non_empty_meta_title(self):
"""
@ -124,10 +125,11 @@ class TestSEOPopulation(TransactionCase):
class TestBlogPostSocialModel(TransactionCase):
"""Verify the itsulu.blog.post.social model stores all platform copy correctly."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_social_record_is_linked_one_to_one_with_blog_post(self):
"""Each blog post has at most one social record."""
@ -198,11 +200,12 @@ class TestNotificationEmail(TransactionCase):
structure matching the [ITSulu Insights] template in the uploaded .eml.
"""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
self.env['ir.config_parameter'].sudo().set_param(
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.notification_emails',
'nicholasr@itsulu.com',
)

View file

@ -15,10 +15,11 @@ from .factories import BlogPublisherFactory
class TestBlogScheduleConfiguration(TransactionCase):
"""Verify that schedule slot records are configured correctly."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_schedule_slot_is_created_with_correct_defaults(self):
"""
@ -68,11 +69,12 @@ class TestBlogScheduleExecution(TransactionCase):
LLM calls are mocked we are testing orchestration, not the LLM.
"""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
self.env['ir.config_parameter'].sudo().set_param(
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
)
@ -82,11 +84,6 @@ class TestBlogScheduleExecution(TransactionCase):
'<h1>AI Governance in 2026</h1>'
'<p>' + ('Enterprise AI paragraph. ' * 30) + '</p>'
)
# _create_blog_post writes body_html into blog.post.content; it must be a
# real string. On Odoo 14 an unset MagicMock attr fails psycopg2 adaptation
# ("can't adapt type 'MagicMock'"); on 17 it silently stringified.
resp.body_html = resp.text
resp.raw_text = resp.text
resp.tokens_used = 900
resp.title = 'AI Governance in 2026'
resp.meta_title = 'AI Governance Frameworks for Enterprises 2026'

View file

@ -15,9 +15,10 @@ from .factories import BlogPublisherFactory
class TestBlogTopicQueueManagement(TransactionCase):
"""Verify that the topic queue picks topics in priority order."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
def test_topic_is_created_with_pending_state(self):
"""

View file

@ -42,20 +42,21 @@ def _make_mock_llm_response(tokens_used=800):
class TestLLMRouterProviderDispatch(TransactionCase):
"""Verify that the LLM router dispatches to the correct backend provider."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
# Store a known-valid stub API key in ir.config_parameter
self.env['ir.config_parameter'].sudo().set_param(
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
)
self.env['ir.config_parameter'].sudo().set_param(
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.openai_api_key', 'sk-openai-test-key'
)
self.env['ir.config_parameter'].sudo().set_param(
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.gemini_api_key', 'gemini-test-key'
)
self.env['ir.config_parameter'].sudo().set_param(
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.ollama_base_url', 'http://localhost:11434'
)
@ -169,10 +170,11 @@ class TestLLMRouterProviderDispatch(TransactionCase):
class TestLLMRouterTokenLogging(TransactionCase):
"""Verify that token usage is captured from provider responses."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.env['ir.config_parameter'].sudo().set_param(
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
)

View file

@ -16,11 +16,12 @@ _logger = logging.getLogger(__name__)
class TestGenerationLatency(TransactionCase):
"""Measure time from run_generation() call to blog.post creation."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
self.env['ir.config_parameter'].sudo().set_param(
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
)
@ -102,11 +103,12 @@ class TestGenerationLatency(TransactionCase):
class TestQueryCount(TransactionCase):
"""Verify N+1 query patterns don't exist in critical paths."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
self.env['ir.config_parameter'].sudo().set_param(
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
)
@ -138,10 +140,8 @@ class TestQueryCount(TransactionCase):
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
return_value=mock_response,
):
# Assert query count budget during generation. Odoo 14's ORM issues a
# few more framework queries than 17 for the same work (~54), so the
# 14.0 budget is 60 (still guards against N+1 explosions, not overhead).
with self.assertQueryCount(60):
# Assert query count < 50 during generation
with self.assertQueryCount(50):
schedule.run_generation()
def test_topic_get_next_topic_uses_single_query(self):
@ -179,10 +179,11 @@ class TestQueryCount(TransactionCase):
class TestTokenUsageBaseline(TransactionCase):
"""Establish token usage baseline for cost tracking."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_typical_post_uses_800_to_1200_tokens(self):
"""
@ -230,11 +231,12 @@ class TestTokenUsageBaseline(TransactionCase):
class TestConcurrentGeneration(TransactionCase):
"""Test that concurrent post generation handles contention correctly."""
def setUp(self):
super().setUp()
self.factory = BlogPublisherFactory(self.env)
self.blog = self.factory.blog(name='ITSulu Insights')
self.env['ir.config_parameter'].sudo().set_param(
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.factory = BlogPublisherFactory(cls.env)
cls.blog = cls.factory.blog(name='ITSulu Insights')
cls.env['ir.config_parameter'].sudo().set_param(
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
)

View file

@ -34,7 +34,7 @@
<form string="Generation Log" create="false" edit="false">
<header>
<button name="action_retry" type="object" string="↩ Retry Generation"
attrs="{'invisible': [('state', '!=', 'error')]}"
invisible="state != 'error'"
class="btn-warning"
data-test-id="btn-retry-log-form"/>
</header>
@ -43,7 +43,7 @@
<group string="Result">
<field name="state" widget="badge"/>
<field name="blog_post_id"/>
<field name="error_message" attrs="{'invisible': [('state', '!=', 'error')]}"/>
<field name="error_message" invisible="state != 'error'"/>
</group>
<group string="Trigger">
<field name="trigger_source"/>

View file

@ -38,7 +38,7 @@
</header>
<sheet>
<widget name="web_ribbon" title="Inactive" bg_color="bg-danger"
attrs="{'invisible': [('active', '=', True)]}"/>
invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Morning Post"/></h1>
</div>

View file

@ -20,10 +20,10 @@
decoration-warning="state=='skipped'"/>
<field name="used_date" optional="show"/>
<button name="action_mark_pending" type="object" string="↩ Reset"
attrs="{'invisible': [('state', '=', 'pending')]}"
invisible="state == 'pending'"
class="btn-sm btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip"
attrs="{'invisible': [('state', '!=', 'pending')]}"
invisible="state != 'pending'"
class="btn-sm btn-warning"/>
</tree>
</field>
@ -36,10 +36,10 @@
<form string="Blog Topic">
<header>
<button name="action_mark_pending" type="object" string="Reset to Pending"
attrs="{'invisible': [('state', '=', 'pending')]}"
invisible="state == 'pending'"
class="btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip"
attrs="{'invisible': [('state', '!=', 'pending')]}"
invisible="state != 'pending'"
class="btn-warning"/>
<field name="state" widget="statusbar" statusbar_visible="pending,used"/>
</header>