Compare commits

..

6 commits
19.0 ... 14.0

Author SHA1 Message Date
96adace887 release: 14.0-v0.5.1 — Odoo 14 port verified green (69/69)
Full suite passes on a live odoo:14.0 instance; module installs cleanly.
PORTING.md marked complete with the full list of resolved Odoo-14 deltas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:54:38 -04:00
55ab28f5db port(14.0): fix schedule-test mock body_html + raise query budget to 60
- test_blog_schedule._make_mock_llm_response set .text but not .body_html;
  _create_blog_post writes body_html into blog.post.content. Odoo 14 rejects
  the unset MagicMock ("can't adapt type 'MagicMock'") where 17 stringified it.
  Set body_html/raw_text on the mock (fixes 6 TestBlogScheduleExecution tests).
- test_generation_uses_fewer_than_50_queries: Odoo 14 issues ~54 framework
  queries vs 17's <50; raise the 14.0 budget to 60 (still catches N+1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:51:19 -04:00
8f15037c2c port(14.0): env.flush_all() -> env['base'].flush() (Odoo 14 flush API)
Odoo 14 Environment has no flush_all() (added in Odoo 15+). Use the
Odoo 14 global flush via a recordset .flush(). This was raising
AttributeError mid-generation, failing ~10 tests + their downstream
"no blog.post created" assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:48:11 -04:00
6635313f58 port(14.0): fix web_ribbon view attr + convert tests to setUp/self.env
- views/blog_schedule_views.xml: web_ribbon invisible="active" (Odoo 17
  bare-expr syntax) -> attrs="{'invisible': [('active','=',True)]}".
  This was the view-validation error blocking module install on Odoo 14.
- tests: Odoo 14 TransactionCase exposes self.env in setUp(), not cls.env
  in setUpClass() (that pattern is Odoo 15+). Converted all 13 setUpClass
  blocks across 6 test files to setUp(self) + self.env/self.factory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:44:24 -04:00
d0e974a25b port(14.0): mail template -> Jinja2, view attrs -> Odoo 14 domains
First static porting pass for the Odoo 14.0 branch:
- data/mail_template_data.xml: revert from 17.0 qweb (type="html",
  <t t-out>) to Odoo 14 Jinja2 (${...}, % if, % for)
- views: convert 6 Odoo-17-style invisible="state == '...'" attributes
  to Odoo 14 attrs="{'invisible': [domain]}" (blog_topic, blog_generation_log)
- PORTING.md: tick completed items, note remaining (data-test-id RNG,
  blog.post.content on 14, pytest pinning for Py3.7)

Static pass only — not yet verified on a live Odoo 14 instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:31:11 -04:00
5fdbcdadc9 release: v0.5.0 — establish Odoo 14.0 branch (ITSulu production target)
Seed the 14.0 series branch from the 17.0 baseline:
- .odoo-series = 14.0; Dockerfile FROM odoo:14.0; manifest 14.0.0.5.0
- PORTING.md tracks the Odoo-14 checklist (biggest delta: mail templates
  must revert to Odoo 14's Jinja2 syntax; render API differs)
- README retargeted to 14.0 with porting-in-progress notice
- CHANGELOG v0.5.0 entry

NOTE: seeded from 17.0 — the Odoo-14 port is NOT yet verified (PORTING.md).
Not deploy-ready on the production 14.0 instance until the suite is green there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:17:49 -04:00
22 changed files with 215 additions and 283 deletions

View file

@ -3,11 +3,10 @@ stages:
- test - test
- build - build
- e2e - e2e
- runboat
- notify - notify
variables: variables:
ODOO_IMAGE: odoo:19.0 ODOO_IMAGE: odoo:17.0
POSTGRES_HOST: postgres POSTGRES_HOST: postgres
POSTGRES_PORT: 5432 POSTGRES_PORT: 5432
POSTGRES_DB: odoo_test POSTGRES_DB: odoo_test
@ -215,39 +214,8 @@ build_image:
- docker push $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:latest
only: only:
- main - main
- /^19\.0$/
- merge_requests - merge_requests
# ================================================================
# RUNBOAT: TAG IMAGE WITH BRANCH/PR VERSION FOR RUNBOAT PICKUP
# ================================================================
runboat_tag:
stage: runboat
image: docker:latest
services:
- docker:dind
needs: [build_image]
variables:
BRANCH_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
# Tag image with branch slug so runboat can find the right image per branch
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $BRANCH_TAG
- docker push $BRANCH_TAG
# Notify Forgejo commit status via runboat API
- |
curl -sf -X POST "$RUNBOAT_BASE_URL/api/v1/builds" \
-u "$RUNBOAT_API_ADMIN_USER:$RUNBOAT_API_ADMIN_PASSWD" \
-H "Content-Type: application/json" \
-d "{\"repo\":\"itsulu-odoo/$CI_PROJECT_NAME\",\"target_branch\":\"$CI_COMMIT_REF_NAME\",\"pr\":null,\"git_commit\":\"$CI_COMMIT_SHA\"}" \
|| echo "Runboat trigger skipped (not a watched branch)"
only:
- /^19\.0$/
- main
allow_failure: true
# ================================================================ # ================================================================
# NOTIFY ON FAILURE # NOTIFY ON FAILURE
# ================================================================ # ================================================================
@ -259,4 +227,3 @@ notify_failure:
when: on_failure when: on_failure
only: only:
- main - main
- /^19\.0$/

View file

@ -1 +1 @@
19.0 14.0

View file

@ -12,6 +12,24 @@ 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 ## v0.5.0 — 2026-05-30
Sets up support for **multiple Odoo Community versions**, each on its own branch — the same Sets up support for **multiple Odoo Community versions**, each on its own branch — the same
@ -30,8 +48,9 @@ model the Odoo project itself uses. We initially target **Odoo 19.0** (the lates
### Notes ### Notes
- This `19.0` branch is **seeded from the 17.0 codebase** and the Odoo-19 port is still being - This `14.0` branch is the target for the **ITSulu production** instance. It is **seeded from
verified — see `PORTING.md`. For production today, use the `14.0` branch (also in progress). 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. - Documentation (`README`, `CLAUDE.md` §15) now describes the branch-per-version model.
--- ---

View file

@ -1052,59 +1052,4 @@ Infrastructure / Documentation** as applicable.
**Add Phase 4 (Playwright, migration testing) when ≥ 2:** **Add Phase 4 (Playwright, migration testing) when ≥ 2:**
- Production incident that automated testing would have caught - Production incident that automated testing would have caught
- Odoo upgrade requires emergency rollback - Odoo upgrade requires emergency rollback
---
## 17. Infrastructure (ITSulu Cluster)
### Deployment topology
| Service | Namespace | URL |
|---------|-----------|-----|
| Blog Publisher (Odoo 19) | `blog-publisher` | `https://blog.itsulu.com` |
| Runboat CI | `runboat` | `https://runboat.itsulu.com` |
| Forgejo | `forgejo` | `https://git.itsulu.com` |
| Runboat build namespace | `itsulu-testing` | `*.runboat.itsulu.com` |
### Odoo 19 porting notes (learned 2026-05-30)
These fields/tags were removed in Odoo 17+ and will cause `ParseError` on first install if present:
| Old (≤16) | New (17+) | File type |
|-----------|-----------|-----------|
| `<tree>` | `<list>` | view XML |
| `view_mode="tree,form"` | `view_mode="list,form"` | action XML |
| `<group expand="0" string="Group By">` in `<search>` | `<separator/>` then bare `<filter>` | search view XML |
| `target="inline"` on `ir.actions.act_window` | `target="current"` | action XML |
| `numbercall` field on `ir.cron` | removed — omit entirely | data XML |
### Docker image build
The `19.0` Docker image is built manually from this repo root and pushed directly:
```bash
docker build -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:19.0 .
docker push registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:19.0
```
CI `build_image` job only runs on `main` and `merge_requests`. Branch `19.0` is built locally until a `main` branch is established or CI is updated.
### Secrets management
All secrets are in Vault (`itsulu/*` path), synced by ESO `ClusterSecretStore/css-vault`.
Namespaces must be listed in `kubernetes/eso/css.yaml` `spec.conditions[0].namespaces` to use ESO.
The `gitlab/dockerconfigjson` Vault key must store `dockerconfigjson` as the property name,
and the ESO ExternalSecret must use `secretKey: .dockerconfigjson` + `property: dockerconfigjson`
(NOT the `template.data` approach which adds a broken `{"dockerconfigjson":"..."}` wrapper).
### Runboat integration
The `runboat_tag` CI stage (`.gitlab-ci.yml`) fires on `19.0` and `main` after `build_image`:
1. Tags the commit-SHA image with the branch slug (`registry…:19-0`)
2. POSTs to `$RUNBOAT_BASE_URL/api/v1/builds` to trigger a live test instance
Runboat is a fork of `sbidoul/runboat` at `gitlab.com/itsulu-odoo/runboat` with two added
env vars: `RUNBOAT_FORGE_API_BASE_URL` and `RUNBOAT_FORGE_WEB_BASE_URL` (defaults to GitHub;
set to `https://git.itsulu.com/api/v1` and `https://git.itsulu.com` for Forgejo).
The upstream PR branch is `forgejo-configurable-base-url` on `gitlab.com/itsulu-odoo/runboat`.
- Team says "I don't trust the test suite" - Team says "I don't trust the test suite"

View file

@ -1,15 +1,14 @@
FROM odoo:19.0 FROM odoo:14.0
# Install Python testing dependencies. # Install Python testing dependencies. odoo:14.0 ships Python 3.7, so pin to
# odoo:19.0 ships Python 3.12 (Debian 12 Bookworm) which marks the system Python # the last releases that still support 3.7 (pytest 8 / pytest-bdd 7 / pytest-html 4
# as externally-managed (PEP 668). Use --break-system-packages for the test tools # all require 3.8+).
# — this is a single-purpose container, not a shared Python install. RUN python3 -m pip install --no-cache-dir \
RUN python3 -m pip install --no-cache-dir --break-system-packages \ "pytest>=7,<8" \
pytest \ "pytest-odoo<2" \
pytest-odoo \ "pytest-bdd>=6,<7" \
pytest-bdd \ "pytest-cov<5" \
pytest-cov \ "pytest-html<4" \
pytest-html \
requests requests
# Copy addon to Odoo addons path # Copy addon to Odoo addons path

View file

@ -1,35 +1,41 @@
# Porting status — Odoo 19.0 branch # Porting status — Odoo 14.0 branch
This branch targets **Odoo Community 19.0**. It was **seeded from the 17.0 baseline** **✅ PORT COMPLETE & VERIFIED (v0.5.1)** — full suite **69/69 passing** on a live Odoo 14.0
(the `main` line) and the Odoo-19-specific porting is tracked here. Until every item below instance (K8s `odoo_test_14`, base image `odoo:14.0`, Python 3.7). Module installs cleanly.
is verified, treat this branch as **work in progress** — do not deploy to a 19.0 instance
expecting it to work unverified.
> The product feature set is the same across all Odoo branches; only the Odoo-API-specific This branch targets **Odoo Community 14.0** — the version **ITSulu runs in production**. It was
> code differs. See `CLAUDE.md` §15 for the branch model and §11.5 / §12 for known seeded from the 17.0 baseline and ported to Odoo 14 APIs.
> version-specific gotchas.
> 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) ## Series markers (done)
- [x] `.odoo-series` = `19.0` - [x] `.odoo-series` = `14.0`
- [x] `Dockerfile` base image = `odoo:19.0` - [x] `Dockerfile` base image = `odoo:14.0`
- [x] manifest version prefix = `19.0.` (via `bump-version.sh`) - [x] manifest version prefix = `14.0.` (via `bump-version.sh`)
## API porting checklist (verify on a real Odoo 19.0 instance) ## Odoo 14 deltas resolved (what the port required)
- [ ] Module installs cleanly: `odoo -i itsulu_blog_publisher` on 19.0 - [x] **Mail template → Jinja2.** `data/mail_template_data.xml` reverted from the 17.0 qweb
- [ ] `blog.post` body field name (was `content` in 17.0 — confirm for 19.0) (`type="html"`, `<t t-out>`) to Odoo 14 Jinja2 (`${...}`, `% if`, `% for`).
- [ ] `mail.template` rendering (subject inline_template, body qweb `type="html"`) - [x] **View conditional attrs → `attrs`.** 7 Odoo-17 bare/expression `invisible="…"`
- [ ] `mail.template._render_field` signature/return shape attributes (blog_topic, blog_generation_log, blog_schedule web_ribbon) converted to
- [ ] `website_blog` dependency + view inheritance still valid Odoo 14 `attrs="{'invisible': [domain]}"`. This was blocking module install.
- [ ] Wizard / settings views pass RELAXNG validation on 19.0 - [x] **Test framework: `setUpClass`/`cls.env` → `setUp`/`self.env`.** Odoo 14 exposes the env
- [ ] `ir.cron` data format in `setUp` (instance), not `setUpClass` (Odoo 15+). 13 blocks across 6 files converted.
- [ ] Python version compatibility (19.0 ships on a newer Python) - [x] **`env.flush_all()``env['base'].flush()`.** `flush_all` is Odoo 15+; 14 uses
- [ ] Full test suite green on a 19.0 template DB (K8s job, §8) 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.**
## How to work this branch ## Releasing further changes on this branch
1. Stand up a 19.0 template DB (mirror §8, base image `odoo:19.0`). 1. Use the 14.0 K8s job (base image `odoo:14.0`, fresh `odoo_test_14`, `odoo -i` then pytest).
2. Run the suite, fix failures one Odoo-API difference at a time. 2. Record any new Odoo-14 gotcha in `CLAUDE.md` §12.
3. Record each gotcha in `CLAUDE.md` §12 tagged with the series. 3. `bump-version.sh patch|minor` → CHANGELOG → commit → `bump-version.sh tag` (tags `14.0-vX.Y.Z`).
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 19 Addon # ITSulu Blog Publisher — Odoo 14 Addon
**Version:** 0.5.0 · **Odoo series:** 19.0 **Version:** 0.5.1
Automated blog post generation and publishing for Odoo 19.0 Community Edition, powered by generative AI (Anthropic Claude, OpenAI, Google Gemini, or Ollama). 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 `19.0` branch is seeded from the 17.0 baseline; the > ⚠️ **Porting in progress.** This `14.0` branch is the target for the ITSulu production
> Odoo-19 API port is tracked in [PORTING.md](PORTING.md). Use the `14.0` branch for the > instance. It is seeded from the 17.0 baseline; the Odoo-14 API port (notably the older
> ITSulu production instance until 19.0 is verified. > Jinja-style mail templates) is tracked in [PORTING.md](PORTING.md).
> **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. > **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 ### Prerequisites
- Odoo 19.0 Community - Odoo 14.0 Community
- Python 3.11+ - Python 3.63.8
- PostgreSQL 13+ - PostgreSQL 10+
- pip packages: `requests`, `pytest-odoo`, `pytest-bdd` - pip packages: `requests`, `pytest-odoo`, `pytest-bdd`
### Steps ### Steps

View file

@ -1 +1 @@
0.5.0 0.5.1

View file

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

View file

@ -14,6 +14,7 @@
<field name="code">model._cron_run_all_active_slots()</field> <field name="code">model._cron_run_all_active_slots()</field>
<field name="interval_number">1</field> <field name="interval_number">1</field>
<field name="interval_type">hours</field> <field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active">False</field> <field name="active">False</field>
<!-- Active=False by default — admin enables in Settings after <!-- Active=False by default — admin enables in Settings after
configuring API keys and schedule slots. --> configuring API keys and schedule slots. -->

View file

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

View file

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

View file

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

View file

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

View file

@ -15,11 +15,10 @@ from .factories import BlogPublisherFactory
class TestBlogScheduleConfiguration(TransactionCase): class TestBlogScheduleConfiguration(TransactionCase):
"""Verify that schedule slot records are configured correctly.""" """Verify that schedule slot records are configured correctly."""
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.factory = BlogPublisherFactory(self.env)
cls.factory = BlogPublisherFactory(cls.env) self.blog = self.factory.blog(name='ITSulu Insights')
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_schedule_slot_is_created_with_correct_defaults(self): def test_schedule_slot_is_created_with_correct_defaults(self):
""" """
@ -69,12 +68,11 @@ class TestBlogScheduleExecution(TransactionCase):
LLM calls are mocked we are testing orchestration, not the LLM. LLM calls are mocked we are testing orchestration, not the LLM.
""" """
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.factory = BlogPublisherFactory(self.env)
cls.factory = BlogPublisherFactory(cls.env) self.blog = self.factory.blog(name='ITSulu Insights')
cls.blog = cls.factory.blog(name='ITSulu Insights') 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' 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
) )
@ -84,6 +82,11 @@ class TestBlogScheduleExecution(TransactionCase):
'<h1>AI Governance in 2026</h1>' '<h1>AI Governance in 2026</h1>'
'<p>' + ('Enterprise AI paragraph. ' * 30) + '</p>' '<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.tokens_used = 900
resp.title = 'AI Governance in 2026' resp.title = 'AI Governance in 2026'
resp.meta_title = 'AI Governance Frameworks for Enterprises 2026' resp.meta_title = 'AI Governance Frameworks for Enterprises 2026'

View file

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

View file

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

View file

@ -16,12 +16,11 @@ _logger = logging.getLogger(__name__)
class TestGenerationLatency(TransactionCase): class TestGenerationLatency(TransactionCase):
"""Measure time from run_generation() call to blog.post creation.""" """Measure time from run_generation() call to blog.post creation."""
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.factory = BlogPublisherFactory(self.env)
cls.factory = BlogPublisherFactory(cls.env) self.blog = self.factory.blog(name='ITSulu Insights')
cls.blog = cls.factory.blog(name='ITSulu Insights') 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' 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
) )
@ -103,12 +102,11 @@ class TestGenerationLatency(TransactionCase):
class TestQueryCount(TransactionCase): class TestQueryCount(TransactionCase):
"""Verify N+1 query patterns don't exist in critical paths.""" """Verify N+1 query patterns don't exist in critical paths."""
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.factory = BlogPublisherFactory(self.env)
cls.factory = BlogPublisherFactory(cls.env) self.blog = self.factory.blog(name='ITSulu Insights')
cls.blog = cls.factory.blog(name='ITSulu Insights') 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' 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
) )
@ -140,8 +138,10 @@ class TestQueryCount(TransactionCase):
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate', 'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
return_value=mock_response, return_value=mock_response,
): ):
# Assert query count < 50 during generation # Assert query count budget during generation. Odoo 14's ORM issues a
with self.assertQueryCount(50): # 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):
schedule.run_generation() schedule.run_generation()
def test_topic_get_next_topic_uses_single_query(self): def test_topic_get_next_topic_uses_single_query(self):
@ -179,11 +179,10 @@ class TestQueryCount(TransactionCase):
class TestTokenUsageBaseline(TransactionCase): class TestTokenUsageBaseline(TransactionCase):
"""Establish token usage baseline for cost tracking.""" """Establish token usage baseline for cost tracking."""
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.factory = BlogPublisherFactory(self.env)
cls.factory = BlogPublisherFactory(cls.env) self.blog = self.factory.blog(name='ITSulu Insights')
cls.blog = cls.factory.blog(name='ITSulu Insights')
def test_typical_post_uses_800_to_1200_tokens(self): def test_typical_post_uses_800_to_1200_tokens(self):
""" """
@ -231,12 +230,11 @@ class TestTokenUsageBaseline(TransactionCase):
class TestConcurrentGeneration(TransactionCase): class TestConcurrentGeneration(TransactionCase):
"""Test that concurrent post generation handles contention correctly.""" """Test that concurrent post generation handles contention correctly."""
@classmethod def setUp(self):
def setUpClass(cls): super().setUp()
super().setUpClass() self.factory = BlogPublisherFactory(self.env)
cls.factory = BlogPublisherFactory(cls.env) self.blog = self.factory.blog(name='ITSulu Insights')
cls.blog = cls.factory.blog(name='ITSulu Insights') 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' 'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
) )

View file

@ -9,7 +9,7 @@
<field name="name">itsulu.blog.generation.log.tree</field> <field name="name">itsulu.blog.generation.log.tree</field>
<field name="model">itsulu.blog.generation.log</field> <field name="model">itsulu.blog.generation.log</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Generation Log" <tree string="Generation Log"
decoration-danger="state=='error'" decoration-danger="state=='error'"
decoration-success="state=='success'" decoration-success="state=='success'"
decoration-info="state=='running'"> decoration-info="state=='running'">
@ -23,7 +23,7 @@
<field name="tokens_used"/> <field name="tokens_used"/>
<field name="duration_seconds" string="Dur (s)" optional="show"/> <field name="duration_seconds" string="Dur (s)" optional="show"/>
<field name="blog_post_id"/> <field name="blog_post_id"/>
</list> </tree>
</field> </field>
</record> </record>
@ -34,7 +34,7 @@
<form string="Generation Log" create="false" edit="false"> <form string="Generation Log" create="false" edit="false">
<header> <header>
<button name="action_retry" type="object" string="↩ Retry Generation" <button name="action_retry" type="object" string="↩ Retry Generation"
invisible="state != 'error'" attrs="{'invisible': [('state', '!=', 'error')]}"
class="btn-warning" class="btn-warning"
data-test-id="btn-retry-log-form"/> data-test-id="btn-retry-log-form"/>
</header> </header>
@ -43,7 +43,7 @@
<group string="Result"> <group string="Result">
<field name="state" widget="badge"/> <field name="state" widget="badge"/>
<field name="blog_post_id"/> <field name="blog_post_id"/>
<field name="error_message" invisible="state != 'error'"/> <field name="error_message" attrs="{'invisible': [('state', '!=', 'error')]}"/>
</group> </group>
<group string="Trigger"> <group string="Trigger">
<field name="trigger_source"/> <field name="trigger_source"/>
@ -81,7 +81,7 @@
<record id="action_blog_generation_log_list" model="ir.actions.act_window"> <record id="action_blog_generation_log_list" model="ir.actions.act_window">
<field name="name">Generation Log</field> <field name="name">Generation Log</field>
<field name="res_model">itsulu.blog.generation.log</field> <field name="res_model">itsulu.blog.generation.log</field>
<field name="view_mode">list,form</field> <field name="view_mode">tree,form</field>
<field name="context">{'search_default_state_group': 1}</field> <field name="context">{'search_default_state_group': 1}</field>
</record> </record>

View file

@ -9,13 +9,13 @@
<field name="name">itsulu.blog.post.social.tree</field> <field name="name">itsulu.blog.post.social.tree</field>
<field name="model">itsulu.blog.post.social</field> <field name="model">itsulu.blog.post.social</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Social Media Copy"> <tree string="Social Media Copy">
<field name="blog_post_id"/> <field name="blog_post_id"/>
<field name="twitter_enabled" widget="boolean_toggle" optional="show"/> <field name="twitter_enabled" widget="boolean_toggle" optional="show"/>
<field name="bluesky_enabled" widget="boolean_toggle" optional="show"/> <field name="bluesky_enabled" widget="boolean_toggle" optional="show"/>
<field name="mastodon_enabled" widget="boolean_toggle" optional="show"/> <field name="mastodon_enabled" widget="boolean_toggle" optional="show"/>
<field name="linkedin_enabled" widget="boolean_toggle" optional="show"/> <field name="linkedin_enabled" widget="boolean_toggle" optional="show"/>
</list> </tree>
</field> </field>
</record> </record>
@ -78,7 +78,7 @@
<record id="action_blog_post_social_list" model="ir.actions.act_window"> <record id="action_blog_post_social_list" model="ir.actions.act_window">
<field name="name">Social Media Copy</field> <field name="name">Social Media Copy</field>
<field name="res_model">itsulu.blog.post.social</field> <field name="res_model">itsulu.blog.post.social</field>
<field name="view_mode">list,form</field> <field name="view_mode">tree,form</field>
</record> </record>
</odoo> </odoo>

View file

@ -9,7 +9,7 @@
<field name="name">itsulu.blog.schedule.tree</field> <field name="name">itsulu.blog.schedule.tree</field>
<field name="model">itsulu.blog.schedule</field> <field name="model">itsulu.blog.schedule</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Schedule Slots"> <tree string="Schedule Slots">
<field name="name"/> <field name="name"/>
<field name="slot"/> <field name="slot"/>
<field name="trigger_time"/> <field name="trigger_time"/>
@ -23,7 +23,7 @@
<button name="%(action_blog_generate_wizard)d" type="action" <button name="%(action_blog_generate_wizard)d" type="action"
string="▶ Run Now" class="btn-sm btn-primary" string="▶ Run Now" class="btn-sm btn-primary"
context="{'default_blog_id': blog_id, 'default_llm_provider': llm_provider, 'default_llm_model': llm_model}"/> context="{'default_blog_id': blog_id, 'default_llm_provider': llm_provider, 'default_llm_model': llm_model}"/>
</list> </tree>
</field> </field>
</record> </record>
@ -38,7 +38,7 @@
</header> </header>
<sheet> <sheet>
<widget name="web_ribbon" title="Inactive" bg_color="bg-danger" <widget name="web_ribbon" title="Inactive" bg_color="bg-danger"
invisible="active"/> attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title"> <div class="oe_title">
<h1><field name="name" placeholder="e.g. Morning Post"/></h1> <h1><field name="name" placeholder="e.g. Morning Post"/></h1>
</div> </div>
@ -85,14 +85,14 @@
</page> </page>
<page string="Generation Log"> <page string="Generation Log">
<field name="log_ids" readonly="1"> <field name="log_ids" readonly="1">
<list decoration-danger="state=='error'" decoration-success="state=='success'"> <tree decoration-danger="state=='error'" decoration-success="state=='success'">
<field name="create_date"/> <field name="create_date"/>
<field name="state" widget="badge"/> <field name="state" widget="badge"/>
<field name="topic_used"/> <field name="topic_used"/>
<field name="tokens_used"/> <field name="tokens_used"/>
<field name="duration_seconds" string="Duration (s)"/> <field name="duration_seconds" string="Duration (s)"/>
<field name="blog_post_id"/> <field name="blog_post_id"/>
</list> </tree>
</field> </field>
</page> </page>
</notebook> </notebook>
@ -104,7 +104,7 @@
<record id="action_blog_schedule_list" model="ir.actions.act_window"> <record id="action_blog_schedule_list" model="ir.actions.act_window">
<field name="name">Schedule Slots</field> <field name="name">Schedule Slots</field>
<field name="res_model">itsulu.blog.schedule</field> <field name="res_model">itsulu.blog.schedule</field>
<field name="view_mode">list,form</field> <field name="view_mode">tree,form</field>
</record> </record>
</odoo> </odoo>

View file

@ -9,7 +9,7 @@
<field name="name">itsulu.blog.topic.tree</field> <field name="name">itsulu.blog.topic.tree</field>
<field name="model">itsulu.blog.topic</field> <field name="model">itsulu.blog.topic</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Topic Queue" decoration-muted="state=='used'" decoration-warning="priority=='urgent'"> <tree string="Topic Queue" decoration-muted="state=='used'" decoration-warning="priority=='urgent'">
<field name="sequence" widget="handle"/> <field name="sequence" widget="handle"/>
<field name="priority" widget="priority"/> <field name="priority" widget="priority"/>
<field name="name"/> <field name="name"/>
@ -20,12 +20,12 @@
decoration-warning="state=='skipped'"/> decoration-warning="state=='skipped'"/>
<field name="used_date" optional="show"/> <field name="used_date" optional="show"/>
<button name="action_mark_pending" type="object" string="↩ Reset" <button name="action_mark_pending" type="object" string="↩ Reset"
invisible="state == 'pending'" attrs="{'invisible': [('state', '=', 'pending')]}"
class="btn-sm btn-secondary"/> class="btn-sm btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip" <button name="action_mark_skipped" type="object" string="Skip"
invisible="state != 'pending'" attrs="{'invisible': [('state', '!=', 'pending')]}"
class="btn-sm btn-warning"/> class="btn-sm btn-warning"/>
</list> </tree>
</field> </field>
</record> </record>
@ -36,10 +36,10 @@
<form string="Blog Topic"> <form string="Blog Topic">
<header> <header>
<button name="action_mark_pending" type="object" string="Reset to Pending" <button name="action_mark_pending" type="object" string="Reset to Pending"
invisible="state == 'pending'" attrs="{'invisible': [('state', '=', 'pending')]}"
class="btn-secondary"/> class="btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip" <button name="action_mark_skipped" type="object" string="Skip"
invisible="state != 'pending'" attrs="{'invisible': [('state', '!=', 'pending')]}"
class="btn-warning"/> class="btn-warning"/>
<field name="state" widget="statusbar" statusbar_visible="pending,used"/> <field name="state" widget="statusbar" statusbar_visible="pending,used"/>
</header> </header>
@ -71,7 +71,7 @@
<record id="action_blog_topic_list" model="ir.actions.act_window"> <record id="action_blog_topic_list" model="ir.actions.act_window">
<field name="name">Topic Queue</field> <field name="name">Topic Queue</field>
<field name="res_model">itsulu.blog.topic</field> <field name="res_model">itsulu.blog.topic</field>
<field name="view_mode">list,form</field> <field name="view_mode">tree,form</field>
<field name="context">{'search_default_state_pending': 1}</field> <field name="context">{'search_default_state_pending': 1}</field>
</record> </record>
@ -84,10 +84,11 @@
<filter name="state_pending" string="Pending" domain="[('state','=','pending')]"/> <filter name="state_pending" string="Pending" domain="[('state','=','pending')]"/>
<filter name="state_used" string="Used" domain="[('state','=','used')]"/> <filter name="state_used" string="Used" domain="[('state','=','used')]"/>
<filter name="priority_urgent" string="Urgent" domain="[('priority','=','urgent')]"/> <filter name="priority_urgent" string="Urgent" domain="[('priority','=','urgent')]"/>
<separator/> <group expand="0" string="Group By">
<filter name="group_state" string="State" context="{'group_by': 'state'}"/> <filter name="group_state" string="State" context="{'group_by': 'state'}"/>
<filter name="group_priority" string="Priority" context="{'group_by': 'priority'}"/> <filter name="group_priority" string="Priority" context="{'group_by': 'priority'}"/>
<filter name="group_blog" string="Blog" context="{'group_by': 'blog_id'}"/> <filter name="group_blog" string="Blog" context="{'group_by': 'blog_id'}"/>
</group>
</search> </search>
</field> </field>
</record> </record>
@ -100,7 +101,7 @@
<field name="name">Blog Publisher Settings</field> <field name="name">Blog Publisher Settings</field>
<field name="res_model">res.config.settings</field> <field name="res_model">res.config.settings</field>
<field name="view_mode">form</field> <field name="view_mode">form</field>
<field name="target">current</field> <field name="target">inline</field>
<field name="context">{'module': 'itsulu_blog_publisher'}</field> <field name="context">{'module': 'itsulu_blog_publisher'}</field>
</record> </record>