Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion template_content_swapper/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=====

Expand Down Expand Up @@ -88,7 +96,7 @@ Credits
Authors
~~~~~~~

* Quartile Limited
* Quartile

Contributors
~~~~~~~~~~~~
Expand Down
5 changes: 3 additions & 2 deletions template_content_swapper/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# 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",
Comment thread
AungKoKoLin1997 marked this conversation as resolved.
"summary": "Swap labels and elements in QWeb templates without custom XPath code",
"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",
Expand Down
108 changes: 88 additions & 20 deletions template_content_swapper/models/ir_qweb.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,119 @@
# 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):
result = super()._render(template, values=values, **options)
if not isinstance(template, str):
return result
result_str = str(result)
lang_code = "en_US"
values = values or {}
request = values.get("request")
Comment thread
AungKoKoLin1997 marked this conversation as resolved.
if request:
# For views
lang_code = request.env.lang
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)
8 changes: 7 additions & 1 deletion template_content_swapper/models/template_content_mapping.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")],
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions template_content_swapper/readme/CONFIGURE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 8 additions & 1 deletion template_content_swapper/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -402,11 +402,18 @@ <h1><a class="toc-backref" href="#toc-entry-1">Configuration</a></h1>
Setting a report record will automatically update the template field.</li>
<li><strong>Template</strong> (required): The main QWeb template (ir.ui.view record) that includes the
string you’d like to replace.</li>
<li><strong>Domain</strong> (optional): Domain used to restrict the records this configuration
applies to. This option is only available for report configurations. Example:
[(‘partner_id’, ‘=’, 1)]</li>
<li><strong>Language</strong> (optional): Target language for string replacement. If left blank, the
replacement will be applied to all languages.</li>
<li><strong>Content From</strong> (required): An existing string to be replaced.</li>
<li><strong>Content To</strong> (optional): A new string to replace the existing string.</li>
</ul>
<p>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.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
Expand All @@ -429,7 +436,7 @@ <h1><a class="toc-backref" href="#toc-entry-4">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-5">Authors</a></h2>
<ul class="simple">
<li>Quartile Limited</li>
<li>Quartile</li>
</ul>
</div>
<div class="section" id="contributors">
Expand Down
98 changes: 72 additions & 26 deletions template_content_swapper/tests/test_template_content_swapper.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,44 +9,90 @@ 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)
.search([("code", "=", "ja_JP")])
)
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": "Page No.:",
"lang": "en_US",
}
self._create_mapping(
content_from="Page:",
content_to="Page No.:",
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("Page No.:" 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="Page No.:",
lang="en_US",
)
result = self._render_report_html(company=test_company, lang="en_US")
self.assertFalse("Page:" in str(result))
self.assertTrue("Page No.:" 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("Page No.:" 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))
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
name="template_id"
attrs="{'readonly':[('report_id','!=',False)]}"
/>
<field name="report_model" invisible="1" />
<field
name="domain"
attrs="{'readonly':[('report_id','=',False)]}"
widget="domain"
options="{'model': 'report_model'}"
/>
<field
name="lang"
attrs="{'invisible':[('active_lang_count', '&lt;=', 1)]}"
Expand Down