Compare commits

..

No commits in common. "14.0" and "v0.4.8" have entirely different histories.
14.0 ... v0.4.8

20 changed files with 168 additions and 320 deletions

View file

@ -1 +0,0 @@
14.0

View file

@ -12,49 +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
model the Odoo project itself uses. We initially target **Odoo 19.0** (the latest stable) and
**Odoo 14.0** (the version ITSulu runs in production).
### Added
- **Per-Odoo-version branches.** The addon now lives on a branch per Odoo release — `19.0`
and `14.0` — so each can track the API differences of its Odoo version independently. Check
out the branch that matches your Odoo install.
- **Version tooling is Odoo-aware.** The release helper (`bump-version.sh`) now reads which
Odoo series a branch targets and stamps the addon and release tags accordingly
(e.g. `19.0-v0.5.0`, `14.0-v0.5.0`), so one product version can ship on several Odoo versions.
- A `PORTING.md` on each version branch tracks what still needs checking for that Odoo release.
### 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`.
- Documentation (`README`, `CLAUDE.md` §15) now describes the branch-per-version model.
---
## v0.4.8 — 2026-05-30
The first tagged release. This version gets the whole test suite running green on the

View file

@ -937,58 +937,25 @@ The project uses a three-part version number, **`MAJOR.MINOR.PATCH`**, each part
| **MINOR** | One or more features added, or a meaningful performance improvement. | new LLM provider, new wizard, 2× faster generation |
| **PATCH** | A single group of commits, or one large commit. | bug-fix batch, doc pass, refactor, dependency bump |
### Odoo version branches (one series per branch — the odoo/odoo model)
The addon targets **multiple Odoo Community releases, one per long-lived branch**, exactly like
[odoo/odoo](https://github.com/odoo/odoo) (`19.0`, `18.0`, …, `14.0`). Initially we ship:
| Branch | Odoo series | Why |
|---|---|---|
| `19.0` | Odoo Community 19.0 | Latest stable — primary supported line |
| `14.0` | Odoo Community 14.0 | The version ITSulu currently runs in production |
Rules:
- **Each Odoo series lives on its own branch.** Never mix two Odoo versions in one branch.
The branch name *is* the series (`19.0`, `14.0`).
- The **product version** (`VERSION`, e.g. `0.5.0`) is shared across series — the same feature
set, ported per Odoo release. The Odoo **manifest** version is `<series>.<product>`
`19.0.0.5.0` on `19.0`, `14.0.0.5.0` on `14.0`.
- `bump-version.sh` resolves the series from `.odoo-series` (a one-line file at repo root),
else the branch name, else `17.0`. The `Dockerfile` base image (`FROM odoo:<series>`) and
the manifest prefix must match the branch.
- **Release tags are namespaced by series** so one product version ships on several branches:
`19.0-v0.5.0`, `14.0-v0.5.0`. (The pre-multi-version release `v0.4.8` predates this scheme.)
- A feature is "done" only when it lands on **every** supported series branch. Port a fix to
each branch (cherry-pick or re-implement) — Odoo APIs differ across 14 → 19, so a clean
cherry-pick is the exception, not the rule.
- Odoo-version-specific gotchas (mail.template syntax, `blog.post` fields, rendering APIs) are
in §11.5 and the §12 failure table. Always confirm which series you are on before fixing.
### Sources of truth & where the version lives
The repo-root **`VERSION`** file is the single source of truth. These are kept in sync:
| File | Form (example on branch `19.0`) | Notes |
| File | Form | Notes |
|---|---|---|
| `VERSION` | `0.5.0` | Source of truth for the product version — plain text, one line. Same across series branches |
| `.odoo-series` | `19.0` | The Odoo series this branch targets (one line). Drives manifest prefix + tag namespace |
| `addons/itsulu_blog_publisher/__manifest__.py` | `'version': '19.0.0.5.0'` | Odoo manifest = `<series>.<product>` |
| `Dockerfile` | `FROM odoo:19.0` | Base image must match the series |
| `README.md` | `**Version:** 0.5.0` | Header line + status footer |
| `CHANGELOG.md` | `## v0.5.0 — <date>` | Release notes section per product version |
| git tag | `19.0-v0.5.0` | Annotated tag, namespaced by series; message = the CHANGELOG section |
| `VERSION` | `0.4.8` | Source of truth — plain text, one line |
| `addons/itsulu_blog_publisher/__manifest__.py` | `'version': '17.0.0.4.8'` | Odoo manifest = `<odoo_series>.<product_version>` = `17.0` + `0.4.8` |
| `README.md` | `**Version:** 0.4.8` | Header line + status footer |
| `CHANGELOG.md` | `## v0.4.8 — <date>` | Release notes section per version |
| git tag | `v0.4.8` | Annotated tag; message = the CHANGELOG section |
**Never hand-edit the version in multiple files.** Run `bump-version.sh` on the target series
branch — it reads `VERSION` + the resolved series and updates `VERSION`, the manifest
(`<series>.<MAJOR>.<MINOR>.<PATCH>`), and README together.
**Never hand-edit the version in multiple files.** Use the helper script — it updates all of
them from `VERSION` and derives the Odoo manifest version (`17.0.<MAJOR>.<MINOR>.<PATCH>`).
### Release process (every version change)
```bash
# Run ON the target series branch (e.g. git checkout 19.0).
# 1. Bump the version (updates VERSION, manifest with the branch's series, README)
# 1. Bump the version (updates VERSION, manifest, README)
scripts/bump-version.sh patch # or: minor | major | set X.Y.Z
# 2. Write the release notes — add a "## vX.Y.Z — <date>" section to CHANGELOG.md
@ -998,13 +965,11 @@ scripts/bump-version.sh patch # or: minor | major | set X.Y.Z
# 3. Commit the bump + notes together
git add -A && git commit -m "release: vX.Y.Z"
# 4. Tag the release — names the tag <series>-vX.Y.Z, message from the CHANGELOG section
# 4. Tag the release — the tag message is taken from the CHANGELOG section
scripts/bump-version.sh tag
# 5. Push branch and the series-namespaced tag (e.g. 19.0-v0.5.0)
git push && git push origin "$(cat .odoo-series 2>/dev/null || echo 17.0)-v$(cat VERSION)"
# 6. Port the same change to the other series branches (14.0, …) and tag each there.
# 5. Push branch and tag
git push && git push origin vX.Y.Z
```
### Release-notes style (common language)
@ -1025,14 +990,12 @@ Infrastructure / Documentation** as applicable.
### Rules
- One tag per `<series>` + product version; tags are immutable. If a release is wrong, ship the
next PATCH — never move a tag. (`v0.4.8` is the pre-multi-version exception.)
- Tag on the series branch **after** the work is merged/finalised, so the tag points at the
- One tag per version; tags are immutable. If a release is wrong, ship the next PATCH — never
move a tag.
- Tag on the release branch **after** the work is merged/finalised, so the tag points at the
commit that actually ships.
- The Odoo manifest version must always be `<branch series>.` + the `VERSION` value (e.g.
`19.0.0.5.0`). The `-u` upgrade path relies on the manifest version increasing, so bump before
deploying schema/data changes.
- A change is not "released" until it is on **every** supported series branch with its own tag.
- The Odoo manifest version must always be `17.0.` + the `VERSION` value. The `-u` upgrade path
relies on the manifest version increasing, so bump before deploying schema/data changes.
---

View file

@ -1,14 +1,12 @@
FROM odoo:14.0
FROM odoo:17.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 +0,0 @@
# Porting status — Odoo 14.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 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.
## 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`)
## Odoo 14 deltas resolved (what the port required)
- [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.**
## Releasing further changes on 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`).

View file

@ -1,21 +1,11 @@
# ITSulu Blog Publisher — Odoo 14 Addon
# ITSulu Blog Publisher — Odoo 17 Addon
**Version:** 0.5.1
**Version:** 0.4.8
Automated blog post generation and publishing for Odoo 14.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).
Automated blog post generation and publishing for Odoo 17 Community Edition, powered by generative AI (Anthropic Claude, OpenAI, Google Gemini, or Ollama).
> **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.
> **Odoo version branches** — like [odoo/odoo](https://github.com/odoo/odoo), each Odoo
> Community release lives on its own branch. Supported targets:
> **[`19.0`](../../tree/19.0)** (latest stable) and **[`14.0`](../../tree/14.0)** (the version
> ITSulu runs). Check out the branch matching your Odoo version. Release tags are namespaced by
> series, e.g. `19.0-v0.5.0`.
## Features
### ✨ Core Functionality
@ -67,9 +57,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 17.0 Community
- Python 3.10+
- PostgreSQL 13+
- pip packages: `requests`, `pytest-odoo`, `pytest-bdd`
### Steps

View file

@ -1 +1 @@
0.5.1
0.4.8

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': '17.0.0.4.8',
'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[
<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> ${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>
</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>

View file

@ -7,31 +7,24 @@
# MINOR feature(s) added, or a performance improvement
# PATCH a single group of commits, or one large commit
#
# The repo-root VERSION file is the single source of truth for the PRODUCT
# version (same feature-set across Odoo branches). We support one Odoo series
# per branch (the odoo/odoo model: branches 19.0, 14.0, ...). The Odoo series
# is resolved from the .odoo-series file, else the branch name, else 17.0.
#
# The Odoo manifest version is derived as <SERIES>.<MAJOR>.<MINOR>.<PATCH>,
# e.g. on branch 19.0 with product 0.5.0 -> manifest 19.0.0.5.0.
#
# Release tags are namespaced by series so one product version can ship on
# multiple branches: 19.0-v0.5.0, 14.0-v0.5.0.
# The repo-root VERSION file is the single source of truth. The Odoo manifest
# version is derived as 17.0.<MAJOR>.<MINOR>.<PATCH> (Odoo-series prefix +
# the product version), e.g. product 0.4.8 -> manifest 17.0.0.4.8.
#
# Usage:
# scripts/bump-version.sh patch # 0.5.0 -> 0.5.1
# scripts/bump-version.sh minor # 0.5.0 -> 0.6.0
# scripts/bump-version.sh major # 0.5.0 -> 1.0.0
# scripts/bump-version.sh patch # 0.4.8 -> 0.4.9
# scripts/bump-version.sh minor # 0.4.8 -> 0.5.0
# scripts/bump-version.sh major # 0.4.8 -> 1.0.0
# scripts/bump-version.sh set 1.2.3 # set an explicit version
# scripts/bump-version.sh tag # annotated tag <series>-v<VERSION>
# scripts/bump-version.sh tag # create annotated git tag v<VERSION>
# # from the CHANGELOG section
#
# Typical flow for a release (run ON the target series branch):
# Typical flow for a release:
# 1) scripts/bump-version.sh minor
# 2) edit CHANGELOG.md — fill in the new "## vX.Y.Z" section (plain language)
# 3) git add -A && git commit -m "release: vX.Y.Z"
# 4) scripts/bump-version.sh tag # tags <series>-vX.Y.Z + CHANGELOG notes
# 5) git push && git push origin <series>-vX.Y.Z
# 4) scripts/bump-version.sh tag # tags + uses the CHANGELOG notes
# 5) git push && git push --tags
#
set -euo pipefail
@ -40,25 +33,10 @@ VERSION_FILE="$ROOT/VERSION"
MANIFEST="$ROOT/addons/itsulu_blog_publisher/__manifest__.py"
README="$ROOT/README.md"
CHANGELOG="$ROOT/CHANGELOG.md"
SERIES_FILE="$ROOT/.odoo-series"
ODOO_SERIES="17.0"
die() { echo "error: $*" >&2; exit 1; }
# The Odoo series this branch targets. We support one Odoo version per branch
# (the odoo/odoo model: branches 19.0, 14.0, ...). Resolution order:
# 1) .odoo-series file at repo root (explicit, authoritative)
# 2) current git branch name, if it looks like a series (e.g. 19.0, 14.0)
# 3) fallback 17.0 (legacy default)
resolve_series() {
if [ -f "$SERIES_FILE" ]; then
tr -d '[:space:]' < "$SERIES_FILE"; return
fi
local br; br="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
if [[ "$br" =~ ^[0-9]+\.0$ ]]; then echo "$br"; return; fi
echo "17.0"
}
ODOO_SERIES="$(resolve_series)"
read_version() {
[ -f "$VERSION_FILE" ] || die "VERSION file not found at $VERSION_FILE"
tr -d '[:space:]' < "$VERSION_FILE"
@ -115,9 +93,7 @@ case "$cmd" in
;;
tag)
v="$current"
# Multi-version tags are namespaced by Odoo series so the same product
# version can ship on several branches: e.g. 19.0-v0.5.0 and 14.0-v0.5.0.
tagname="${ODOO_SERIES}-v${v}"
tagname="v${v}"
git -C "$ROOT" rev-parse "$tagname" >/dev/null 2>&1 && die "tag $tagname already exists"
# Extract this version's section from CHANGELOG (from "## vX.Y.Z" to the next "## v" or "---").
notes="$(awk -v ver="## v${v}" '