diff --git a/template_content_swapper/README.rst b/template_content_swapper/README.rst
index 861e833866..6a62d317e4 100644
--- a/template_content_swapper/README.rst
+++ b/template_content_swapper/README.rst
@@ -54,11 +54,19 @@ Following fields should be filled in:
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
=====
@@ -88,7 +96,7 @@ Credits
Authors
~~~~~~~
-* Quartile Limited
+* Quartile
Contributors
~~~~~~~~~~~~
diff --git a/template_content_swapper/__manifest__.py b/template_content_swapper/__manifest__.py
index eed770b9d6..d6589f086a 100644
--- a/template_content_swapper/__manifest__.py
+++ b/template_content_swapper/__manifest__.py
@@ -1,9 +1,9 @@
-# Copyright 2024 Quartile Limited
+# Copyright 2024 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Template Content Swapper",
"version": "16.0.1.1.0",
- "author": "Quartile Limited, Odoo Community Association (OCA)",
+ "author": "Quartile, Odoo Community Association (OCA)",
"license": "AGPL-3",
"category": "Tools",
"website": "https://github.com/OCA/server-ux",
diff --git a/template_content_swapper/models/ir_qweb.py b/template_content_swapper/models/ir_qweb.py
index 65726586ca..940f4ecb27 100644
--- a/template_content_swapper/models/ir_qweb.py
+++ b/template_content_swapper/models/ir_qweb.py
@@ -1,17 +1,69 @@
-# Copyright 2024 Quartile Limited
+# 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):
@@ -19,7 +71,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
@@ -27,25 +78,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 92672965d8..c5f2f6c3bd 100644
--- a/template_content_swapper/models/template_content_mapping.py
+++ b/template_content_swapper/models/template_content_mapping.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 import api, fields, models
@@ -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.rst b/template_content_swapper/readme/CONFIGURE.rst
index 5c62e9754c..7475d633b4 100644
--- a/template_content_swapper/readme/CONFIGURE.rst
+++ b/template_content_swapper/readme/CONFIGURE.rst
@@ -7,7 +7,15 @@ Following fields should be filled in:
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 199c708cdf..1a2c21e730 100644
--- a/template_content_swapper/static/description/index.html
+++ b/template_content_swapper/static/description/index.html
@@ -8,11 +8,10 @@
/*
:Author: David Goodger (goodger@python.org)
-:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
+:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
-Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
@@ -275,7 +274,7 @@
margin-left: 2em ;
margin-right: 2em }
-pre.code .ln { color: gray; } /* line numbers */
+pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
@@ -301,7 +300,7 @@
span.pre {
white-space: pre }
-span.problematic, pre.problematic {
+span.problematic {
color: red }
span.section-subtitle {
@@ -402,11 +401,18 @@
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.
@@ -429,7 +435,7 @@
-- Quartile Limited
+- Quartile
@@ -446,9 +452,7 @@
This module is maintained by the OCA.
-
-
-
+
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
diff --git a/template_content_swapper/tests/test_template_content_swapper.py b/template_content_swapper/tests/test_template_content_swapper.py
index 4957e4753a..6dc2ae2920 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 0936ae60a3..759e6c8e69 100644
--- a/template_content_swapper/views/template_content_mapping_views.xml
+++ b/template_content_swapper/views/template_content_mapping_views.xml
@@ -11,6 +11,13 @@
name="template_id"
attrs="{'readonly':[('report_id','!=',False)]}"
/>
+
+