From f82125b5959c4a66c5a4bcae9f0df16b65b22484 Mon Sep 17 00:00:00 2001 From: Aungkokolin1997 Date: Tue, 9 Dec 2025 06:27:06 +0000 Subject: [PATCH 1/2] [IMP] template_content_swapper: add feature to use domain for applying some specific records in report pdfs Co-authored-by: Yoshi Tashiro --- template_content_swapper/README.rst | 51 +++++---- template_content_swapper/models/ir_qweb.py | 105 ++++++++++++++---- .../models/template_content_mapping.py | 6 + template_content_swapper/readme/CONFIGURE.md | 30 +++-- .../static/description/index.html | 40 +++---- .../tests/test_template_content_swapper.py | 2 +- .../views/template_content_mapping_views.xml | 7 ++ 7 files changed, 167 insertions(+), 74 deletions(-) diff --git a/template_content_swapper/README.rst b/template_content_swapper/README.rst index f29f461fd7..ec92d18137 100644 --- a/template_content_swapper/README.rst +++ b/template_content_swapper/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======================== Template Content Swapper ======================== @@ -17,7 +13,7 @@ Template Content Swapper .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--ux-lightgray.png?logo=github @@ -40,10 +36,10 @@ template.content.mapping records for the templates they wish to modify. Examples: -- Replace 'Salesperson' label with 'Sales Representative' in the - quotation print. -- Replace 'Add to Cart' button with 'Add to Basket' in the eCommerce - product page. +- Replace 'Salesperson' label with 'Sales Representative' in the + quotation print. +- Replace 'Add to Cart' button with 'Add to Basket' in the eCommerce + product page. **Table of contents** @@ -58,16 +54,25 @@ Mappings* to create/maintain records. Following fields should be filled in: -- **Report** (optional): Report record that includes the string you'd - like to replace. Setting a report record will automatically update the - template field. -- **Template** (required): The main QWeb template (ir.ui.view record) - that includes the string you'd like to replace. -- **Language** (optional): Target language for string replacement. If - left blank, the replacement will be applied to all languages. -- **Content From** (required): An existing string to be replaced. -- **Content To** (optional): A new string to replace the existing - string. +- **Report** (optional): Report record that includes the string you'd + like to replace. Setting a report record will automatically update + the template field. +- **Template** (required): The main QWeb template (ir.ui.view record) + that includes the string you'd like to replace. +- **Domain** (optional): Domain used to restrict the records this + configuration applies to. This option is only available for report + configurations. Example: [('partner_id', '=', 1)] +- **Language** (optional): Target language for string replacement. If + left blank, the replacement will be applied to all languages. +- **Content From** (required): An existing string to be replaced. +- **Content To** (optional): A new string to replace the existing + string. + +As a limitation, domain-based configurations that change content outside +the article section (for example, header or footer content) only work +when printing a single record. When multiple records are printed in one +batch, those domain conditions are not applied to the header/footer and +only affect the article content. Usage ===== @@ -107,11 +112,11 @@ Authors Contributors ------------ -- `Quartile `__: +- `Quartile `__: - - Aung Ko Ko Lin - - Yoshi Tashiro - - Tatsuki Kanda + - Aung Ko Ko Lin + - Yoshi Tashiro + - Tatsuki Kanda Maintainers ----------- diff --git a/template_content_swapper/models/ir_qweb.py b/template_content_swapper/models/ir_qweb.py index 8ce7e55ef9..be5ac7f8ce 100644 --- a/template_content_swapper/models/ir_qweb.py +++ b/template_content_swapper/models/ir_qweb.py @@ -1,17 +1,69 @@ # Copyright 2024 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging import re +from lxml import html from markupsafe import Markup from odoo import api, models from odoo.tools.profiler import QwebTracker +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + +ARTICLE_XPATH = '//div[contains(@class, "article") and @data-oe-model and @data-oe-id]' class IrQWeb(models.AbstractModel): _inherit = "ir.qweb" + def _apply_mappings(self, html_str, mappings, model_name=None, res_id=None): + """Apply mappings to HTML string, optionally filtering by record domain.""" + for m in mappings: + if m.domain: + if m.report_model and m.report_model != model_name: + continue + if not self._record_matches_domain(model_name, res_id, m.domain): + continue + html_str = html_str.replace(m.content_from, m.content_to or "") + return html_str + + def _record_matches_domain(self, model_name, res_id, domain_str): + """Check if record (model_name, res_id) matches the given domain.""" + try: + dom = safe_eval(domain_str) + except Exception: + _logger.warning( + "Invalid domain on template.content.mapping for %s,%s: %s", + model_name, + res_id, + domain_str, + ) + return False + return bool(self.env[model_name].search_count([("id", "=", res_id)] + dom)) + + def _apply_mappings_on_articles(self, articles, domain_mappings): + """Apply domain mappings per article block for multi-record renders.""" + for article in articles: + article_html = html.tostring(article, encoding="unicode") + new_html = self._apply_mappings( + article_html, + domain_mappings, + article.get("data-oe-model"), + int(article.get("data-oe-id")), + ) + if new_html != article_html: + try: + article.getparent().replace(article, html.fromstring(new_html)) + except Exception: + _logger.exception( + "Failed to replace article HTML for %s,%s", + article.get("data-oe-model"), + article.get("data-oe-id"), + ) + @QwebTracker.wrap_render @api.model def _render(self, template, values=None, **options): @@ -20,7 +72,6 @@ def _render(self, template, values=None, **options): if not isinstance(template, str): return result result_str = str(result) - lang_code = "en_US" request = values.get("request") if request: # For views @@ -28,25 +79,41 @@ def _render(self, template, values=None, **options): else: # For reports lang_match = re.search(r'data-oe-lang="([^"]+)"', result_str) - if lang_match: - lang_code = lang_match.group(1) + lang_code = lang_match.group(1) if lang_match else "en_US" view = self.env["ir.ui.view"]._get(template) - content_mappings = ( + mappings = ( self.env["template.content.mapping"] .sudo() - .search( - [ - ("template_id", "=", view.id), - "|", - ("lang", "=", lang_code), - ("lang", "=", False), - ] - ) + .search([("template_id", "=", view.id), ("lang", "in", [lang_code, False])]) ) - if content_mappings: - for mapping in content_mappings: - content_from = mapping.content_from - content_to = mapping.content_to or "" - result_str = result_str.replace(content_from, content_to) - result = Markup(result_str) - return result + if not mappings: + return result + global_mappings = [m for m in mappings if not m.domain] + domain_mappings = [m for m in mappings if m.domain] + result_str = self._apply_mappings(result_str, global_mappings) + if not domain_mappings: + return Markup(result_str) + try: + root = html.fromstring(result_str) + except Exception: + _logger.warning( + "Failed to parse HTML for template %s, skipping domain-based mappings.", + template, + ) + return Markup(result_str) + articles = root.xpath(ARTICLE_XPATH) + if not articles: + return Markup(result_str) + if len(articles) == 1: + # Single record → domain mappings can be applied globally + article = articles[0] + result_str = self._apply_mappings( + result_str, + domain_mappings, + article.get("data-oe-model"), + int(article.get("data-oe-id")), + ) + return Markup(result_str) + self._apply_mappings_on_articles(articles, domain_mappings) + final_html = html.tostring(root, encoding="unicode") + return Markup(final_html) diff --git a/template_content_swapper/models/template_content_mapping.py b/template_content_swapper/models/template_content_mapping.py index b4f47fac08..2f84be73f5 100644 --- a/template_content_swapper/models/template_content_mapping.py +++ b/template_content_swapper/models/template_content_mapping.py @@ -15,6 +15,7 @@ def _lang_get(self): name = fields.Char(compute="_compute_name", store=True, readonly=True) report_id = fields.Many2one("ir.actions.report") + report_model = fields.Char(related="report_id.model") template_id = fields.Many2one( "ir.ui.view", domain=[("type", "=", "qweb")], @@ -25,6 +26,11 @@ def _lang_get(self): precompute=True, help="Select the main template of the report / frontend page to be modified.", ) + domain = fields.Char( + help="Optional domain on the report records. The mapping is applied " + "only if the record in the report matches this domain. " + "Example: [('partner_id', '=', 1)]", + ) lang = fields.Selection( _lang_get, string="Language", diff --git a/template_content_swapper/readme/CONFIGURE.md b/template_content_swapper/readme/CONFIGURE.md index 06d189e19c..7475d633b4 100644 --- a/template_content_swapper/readme/CONFIGURE.md +++ b/template_content_swapper/readme/CONFIGURE.md @@ -1,15 +1,21 @@ -Go to *Settings \> Technical \> User Interface \> Template Content -Mappings* to create/maintain records. +Go to *Settings > Technical > User Interface > Template Content Mappings* to +create/maintain records. Following fields should be filled in: -- **Report** (optional): Report record that includes the string you'd - like to replace. Setting a report record will automatically update the - template field. -- **Template** (required): The main QWeb template (ir.ui.view record) - that includes the string you'd like to replace. -- **Language** (optional): Target language for string replacement. If - left blank, the replacement will be applied to all languages. -- **Content From** (required): An existing string to be replaced. -- **Content To** (optional): A new string to replace the existing - string. +* **Report** (optional): Report record that includes the string you'd like to replace. + Setting a report record will automatically update the template field. +* **Template** (required): The main QWeb template (ir.ui.view record) that includes the + string you'd like to replace. +* **Domain** (optional): Domain used to restrict the records this configuration + applies to. This option is only available for report configurations. Example: + [('partner_id', '=', 1)] +* **Language** (optional): Target language for string replacement. If left blank, the + replacement will be applied to all languages. +* **Content From** (required): An existing string to be replaced. +* **Content To** (optional): A new string to replace the existing string. + +As a limitation, domain-based configurations that change content outside the article +section (for example, header or footer content) only work when printing a single +record. When multiple records are printed in one batch, those domain conditions are +not applied to the header/footer and only affect the article content. diff --git a/template_content_swapper/static/description/index.html b/template_content_swapper/static/description/index.html index b25ebdbfe5..ad1ef403b6 100644 --- a/template_content_swapper/static/description/index.html +++ b/template_content_swapper/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Template Content Swapper -
+
+

Template Content Swapper

- - -Odoo Community Association - -
-

Template Content Swapper

-

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

This module offers a generic functionality for replacing QWeb view elements. Typically, element replacements are conducted through a template using an XPATH replacement by creating a new module. With this @@ -402,25 +397,33 @@

Template Content Swapper

-

Configuration

+

Configuration

Go to Settings > Technical > User Interface > Template Content Mappings to create/maintain records.

Following fields should be filled in:

  • Report (optional): Report record that includes the string you’d -like to replace. Setting a report record will automatically update the -template field.
  • +like to replace. Setting a report record will automatically update +the template field.
  • Template (required): The main QWeb template (ir.ui.view record) that includes the string you’d like to replace.
  • +
  • Domain (optional): Domain used to restrict the records this +configuration applies to. This option is only available for report +configurations. Example: [(‘partner_id’, ‘=’, 1)]
  • Language (optional): Target language for string replacement. If left blank, the replacement will be applied to all languages.
  • Content From (required): An existing string to be replaced.
  • Content To (optional): A new string to replace the existing string.
+

As a limitation, domain-based configurations that change content outside +the article section (for example, header or footer content) only work +when printing a single record. When multiple records are printed in one +batch, those domain conditions are not applied to the header/footer and +only affect the article content.

-

Usage

+

Usage

To use this module, first configure the template content mappings.

image

Then, go to the UI where your configured template is utilized.

@@ -428,7 +431,7 @@

Usage

image2

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -436,15 +439,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Quartile
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -470,6 +473,5 @@

Maintainers

-
diff --git a/template_content_swapper/tests/test_template_content_swapper.py b/template_content_swapper/tests/test_template_content_swapper.py index 46584f3184..642e8bdf75 100644 --- a/template_content_swapper/tests/test_template_content_swapper.py +++ b/template_content_swapper/tests/test_template_content_swapper.py @@ -1,4 +1,4 @@ -# Copyright 2024 Quartile Limited +# Copyright 2024 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo.tests.common import TransactionCase diff --git a/template_content_swapper/views/template_content_mapping_views.xml b/template_content_swapper/views/template_content_mapping_views.xml index 57bd342bf8..8602b7484e 100644 --- a/template_content_swapper/views/template_content_mapping_views.xml +++ b/template_content_swapper/views/template_content_mapping_views.xml @@ -8,6 +8,13 @@ + + From 0d5cc3f1971d8fa31f4312dac7eca26522e1709e Mon Sep 17 00:00:00 2001 From: Aungkokolin1997 Date: Tue, 13 Jan 2026 07:59:57 +0000 Subject: [PATCH 2/2] rework on tests --- .../tests/test_template_content_swapper.py | 96 ++++++++++++++----- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/template_content_swapper/tests/test_template_content_swapper.py b/template_content_swapper/tests/test_template_content_swapper.py index 642e8bdf75..12f1b67978 100644 --- a/template_content_swapper/tests/test_template_content_swapper.py +++ b/template_content_swapper/tests/test_template_content_swapper.py @@ -9,6 +9,9 @@ class TestTemplateStringSwapper(TransactionCase): def setUpClass(cls): super().setUpClass() cls.view_obj = cls.env["ir.ui.view"] + cls.main_company = cls.env.company + cls.report = cls.env.ref("web.action_report_externalpreview") + cls.template = cls.report.report_name ja = ( cls.env["res.lang"] .with_context(active_test=False) @@ -16,37 +19,80 @@ def setUpClass(cls): ) cls.env["base.language.install"].create({"lang_ids": ja.ids}).lang_install() + def _render_report_html(self, company=None, lang=None): + company = company or self.env.company + view_obj = self.view_obj + if company: + view_obj = view_obj.with_company(company) + if lang: + view_obj = view_obj.with_context(lang=lang) + view = view_obj._get(self.template).sudo() + values = {"company": company, "report_type": "pdf", "o": view} + return view_obj._render_template(self.template, values) + + def _create_mapping(self, content_from, content_to, lang, domain=None): + vals = { + "report_id": self.report.id, + "content_from": content_from, + "content_to": content_to, + "lang": lang, + } + if domain: + vals["domain"] = domain + return self.env["template.content.mapping"].create(vals) + def test_template_string_swapper(self): - template = "web.external_layout" - view = self.view_obj._get(template).sudo() - values = {"company": self.env.company, "report_type": "pdf", "o": view} - result = self.view_obj._render_template(template, values) + result = self._render_report_html(lang="en_US") self.assertTrue("Page" in str(result)) - self.env["template.content.mapping"].create( - { - "template_id": view.id, - "content_from": "Page", - "content_to": "Slide", - "lang": "en_US", - } + self._create_mapping( + content_from="Page", + content_to="Slide", + lang="en_US", ) - result = self.view_obj._render_template(template, values) + result = self._render_report_html(lang="en_US") self.assertFalse("Page" in str(result)) self.assertTrue("Slide" in str(result)) - # Switch the language to Japanese - view_obj = self.view_obj.with_context(lang="ja_JP") - view = view_obj.browse(view.id) - values = {"company": self.env.company, "report_type": "pdf", "o": view} - result = view_obj._render_template(template, values) + # JA + result = self._render_report_html(lang="ja_JP") self.assertTrue("ページ" in str(result)) - self.env["template.content.mapping"].create( - { - "template_id": view.id, - "content_from": "ページ", - "content_to": "スライド", - "lang": "ja_JP", - } + self._create_mapping( + content_from="ページ", + content_to="スライド", + lang="ja_JP", ) - result = view_obj._render_template(template, values) + result = self._render_report_html(lang="ja_JP") self.assertFalse("ページ" in str(result)) self.assertTrue("スライド" in str(result)) + + def test_template_string_swapper_with_domain(self): + test_company = self.env["res.company"].create({"name": "Test Company"}) + domain = f"[('id', '=', {test_company.id})]" + # EN for test_company + result = self._render_report_html(company=test_company, lang="en_US") + self.assertTrue("Page" in str(result)) + self._create_mapping( + domain=domain, + content_from="Page", + content_to="Slide", + lang="en_US", + ) + result = self._render_report_html(company=test_company, lang="en_US") + self.assertFalse("Page" in str(result)) + self.assertTrue("Slide" in str(result)) + # Ensure it doesn't apply to main company + result = self._render_report_html(company=self.main_company, lang="en_US") + self.assertFalse("Slide" in str(result)) + # JA for test_company + result = self._render_report_html(company=test_company, lang="ja_JP") + self.assertTrue("ページ" in str(result)) + self._create_mapping( + domain=domain, + content_from="ページ", + content_to="スライド", + lang="ja_JP", + ) + result = self._render_report_html(company=test_company, lang="ja_JP") + self.assertTrue("スライド" in str(result)) + # Ensure it doesn't apply to main company (JA) + result = self._render_report_html(company=self.main_company, lang="ja_JP") + self.assertFalse("スライド" in str(result))