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
101 changes: 101 additions & 0 deletions src/base/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2742,3 +2742,104 @@ def test_assert_updated_combo(self):
):
p2.city = "Niflheim"
util.flush(p2)


class TestReportUtils(UnitTestCase):
def test_report_simple(self):
self.assertEqual(
util.report_with_list(
summary="Simple test with minimal arguments and no data.",
data=[],
columns=("id", "name"),
row_format="Partner {name} has id {id}.",
),
"<summary>Simple test with minimal arguments and no data.</summary>",
)

def test_report_links(self):
self.assertEqual(
util.report_with_list(
summary="Testing links.",
data=[],
columns=("id", "name"),
row_format="Partner {partner_link}.",
links={"partner_link": ("res.partner", "id", "name")},
),
"<summary>Testing links.</summary>",
)

def test_report_data_minimal(self):
href = (
"/odoo/res.partner/1?debug=1"
if util.version_gte("18.0")
else "/web?debug=1#view_type=form&amp;model=res.partner&amp;action=&amp;id=1"
)
expected = (
"<summary>Test with minimal data.<details><i></i><ul>\n"
'<li>Partner <a target="_blank" href="{}">Partner One</a>.</li>\n'
"</ul></details></summary>"
).format(href)
self.assertEqual(
util.report_with_list(
summary="Test with minimal data.",
data=[(1, "Partner One")],
columns=("id", "name"),
row_format="Partner {partner_link}.",
links={"partner_link": ("res.partner", "id", "name")},
),
expected,
)

def test_report_data_limited(self):
if util.version_gte("18.0"):
href1 = "/odoo/res.partner/1?debug=1"
href2 = "/odoo/res.partner/2?debug=1"
else:
href1 = "/web?debug=1#view_type=form&amp;model=res.partner&amp;action=&amp;id=1"
href2 = "/web?debug=1#view_type=form&amp;model=res.partner&amp;action=&amp;id=2"
expected = (
"<summary>Test with limited data.<details><i>The total number of affected records is 3. This list is showing the first 2 records.</i><ul>\n"
'<li>Partner <a target="_blank" href="{}">Partner One</a>.</li>\n'
'<li>Partner <a target="_blank" href="{}">Partner Two</a>.</li>\n'
"</ul></details></summary>"
).format(href1, href2)
self.assertEqual(
util.report_with_list(
summary="Test with limited data.",
data=[(1, "Partner One"), (2, "Partner Two"), (3, "Partner Three")],
columns=("id", "name"),
row_format="Partner {partner_link}.",
links={"partner_link": ("res.partner", "id", "name")},
total=3,
limit=2,
),
expected,
)

def test_report_data_limitless(self):
if util.version_gte("18.0"):
href1 = "/odoo/res.partner/1?debug=1"
href2 = "/odoo/res.partner/2?debug=1"
href3 = "/odoo/res.partner/3?debug=1"
else:
href1 = "/web?debug=1#view_type=form&amp;model=res.partner&amp;action=&amp;id=1"
href2 = "/web?debug=1#view_type=form&amp;model=res.partner&amp;action=&amp;id=2"
href3 = "/web?debug=1#view_type=form&amp;model=res.partner&amp;action=&amp;id=3"
expected = (
"<summary>Test with limitless data.<details><i></i><ul>\n"
'<li>Partner <a target="_blank" href="{}">Partner One</a>.</li>\n'
'<li>Partner <a target="_blank" href="{}">Partner Two</a>.</li>\n'
'<li>Partner <a target="_blank" href="{}">Partner Three</a>.</li>\n'
"</ul></details></summary>"
).format(href1, href2, href3)
self.assertEqual(
util.report_with_list(
summary="Test with limitless data.",
data=[(1, "Partner One"), (2, "Partner Two"), (3, "Partner Three")],
columns=("id", "name"),
row_format="Partner {partner_link}.",
links={"partner_link": ("res.partner", "id", "name")},
limit=None,
),
expected,
)
65 changes: 38 additions & 27 deletions src/util/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def make_index_name(table_name, column_name):
target_of,
)
from .records import _remove_import_export_paths
from .report import add_to_migration_reports, get_anchor_link_to_record
from .report import add_to_migration_reports, report_with_list

# python3 shims
try:
Expand Down Expand Up @@ -1597,11 +1597,26 @@ def _update_field_usage_multi(cr, models, old, new, domain_adapter=None, skip_in
)
""".format(col_prefix=col_prefix, name=get_value_or_en_translation(cr, "ir_act_server", "name"))
cr.execute(q, {"old_pattern": p["old_pattern"], "old": p["old"], "standard_modules": tuple(standard_modules)})
li = ""
if cr.rowcount:
li = "".join(
"<li>{}</li>".format(get_anchor_link_to_record("ir.actions.server", aid, aname))
for aid, aname in cr.fetchall()

model_text = "All models"
if only_models:
model_text = "Models " + ", ".join("<kbd>{}</kbd>".format(m) for m in only_models)
total = cr.rowcount
if total:
summary = (
"{model_text}: the field <kbd>{old}</kbd> has been renamed to <kbd>{new}</kbd>. "
"The following server actions may need an update. "
"If a server action is a standard one and you haven't made any modifications, you may ignore it."
).format(model_text=model_text, old=old, new=new)
report_with_list(
summary=summary,
data=cr.fetchall(),
columns=("id", "name"),
row_format="{action_link}",
links={"action_link": ("ir.actions.server", "id", "name")},
total=total,
limit=20,
category="Fields renamed",
)

if column_exists(cr, "ir_model_fields", "compute"):
Expand All @@ -1620,28 +1635,24 @@ def _update_field_usage_multi(cr, models, old, new, domain_adapter=None, skip_in
)
"""
cr.execute(q, {"old_pattern": p["old_pattern"], "standard_modules": tuple(standard_modules)})
if cr.rowcount:
li += "".join(
"<li>{}</li>".format(get_anchor_link_to_record("ir.model.fields", fid, fname))
for fid, fname in cr.fetchall()

total = cr.rowcount
if total:
summary = (
"{model_text}: the field <kbd>{old}</kbd> has been renamed to <kbd>{new}</kbd>. "
"The following compute methods of other fields may need an update. "
"If a field is a standard one and you haven't made any modifications, you may ignore it."
).format(model_text=model_text, old=old, new=new)
report_with_list(
summary=summary,
data=cr.fetchall(),
columns=("id", "name"),
row_format="{field_link}",
links={"field_link": ("ir.model.fields", "id", "name")},
total=total,
limit=20,
category="Fields renamed",
)
if li:
model_text = "All models"
if only_models:
model_text = "Models " + ", ".join("<kbd>{}</kbd>".format(m) for m in only_models)
add_to_migration_reports(
"""
<details>
<summary>
{model_text}: the field <kbd>{old}</kbd> has been renamed to <kbd>{new}</kbd>. The following server actions and compute methods of other fields may need an update.
If a server action or a field is a standard one and you haven't made any modifications, you may ignore them.
</summary>
<ul>{li}</ul>
</details>
""".format(**locals()),
category="Fields renamed",
format="html",
)

# if we stay on the same model. (no usage of dotted-path) (only works for domains and related)
if "." not in old and "." not in new:
Expand Down
87 changes: 86 additions & 1 deletion src/util/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def html_escape(text):
}


def add_to_migration_reports(message, category="Other", format="text"):
def report(message, category="Other", format="text"):
assert format in {"text", "html", "md", "rst"}
if format == "md":
message = md2html(dedent(message))
Expand All @@ -127,6 +127,91 @@ def add_to_migration_reports(message, category="Other", format="text"):
_logger.warning("Upgrade report is growing suspiciously long: %s characters so far.", migration_reports_length)


add_to_migration_reports = report


def report_with_summary(summary, details, category="Other"):
"""Append the upgrade report with a new entry.

:param str summary: Description of a report entry.
:param str details: Detailed description that is going to be folded by default.
:param str category: Title of a report entry.
"""
msg = (
"<summary>{}<details>{}</details></summary>".format(summary, details)
if details
else "<summary>{}</summary>".format(summary)
)
report(message=msg, category=category, format="html")
return msg


def report_with_list(summary, data, columns, row_format, links=None, total=None, limit=100, category="Other"):
"""Append the upgrade report with a new entry that displays a list of records.

The entry consists of a category (title) and a summary (body).
The entry displays a list of records previously returned by SQL query, or any list.

.. example::

.. code-block:: python

total = cr.rowcount
data = cr.fetchmany(20)
util.report_with_list(
summary="The following records were altered.",
data=data,
columns=("id", "name", "city", "comment", "company_id", "company_name"),
row_format="Partner with id {partner_link} works at company {company_link} in {city}, ({comment})",
links={"company_link": ("res.company", "company_id", "company_name"), "partner_link": ("res.partner", "id", "name")},
total=total,
category="Accounting"
)

:param str summary: description of a report entry.
:param list(tuple) data: data to report, each entry would be a row in the report.
It could be empty, in which case only the summary is rendered.
:param tuple(str) columns: columns in `data`, can be referenced in `row_format`.
:param str row_format: format for rows, can use any name from `columns` or `links`, e.g.:
"Partner {partner_link} that lives in {city} works at company {company_link}."
:param dict(str, tuple(str, str, str)) links: optional model/record links spec,
the keys can be referenced in `row_format`.
:param int total: optional, total number of records.
Taken as `len(data)` when `None` is passed.
Useful when `data` was limited by the caller.
:param int limit: maximum number of records to list in the report.
If `data` contains more records than `limit`, the `total`
number would be included in the report as well.
Set `None` for no limit.
:param str category: title of a report entry.
"""

def row_to_html(row):
row_dict = dict(zip(columns, row))
if links:
row_dict.update(
{
link: get_anchor_link_to_record(rec_model, row_dict[id_col], row_dict[name_col])
for link, (rec_model, id_col, name_col) in links.items()
}
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we generate the link items (which are already escaped) I think we need to escape the remaining values in the dict.
For example imagine that row_format is "{some_field}" and in the dict we get {"some_field": "<script>window.alert('escaped')</script>"}

Suggested change
)
)
row_dict = {col: html_escape(val) for col, val in row_dict.items()}

cc: @KangOl

row_dict = {col: html_escape(val) for col, val in row_dict.items()}
return "<li>{}</li>".format(row_format.format(**row_dict))

if not data:
row_to_html(columns) # Validate the format is correct, including links
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will fail. column doesn't have the expected type.

(a.k.a tests are missing)

Copy link
Copy Markdown
Contributor

@aj-fuentes aj-fuentes Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is still unsolved. The problem here is that if we pass row_links, when data is empty the zip(columns, columns) won't have the right values for row_dict[id_col] that must be an int. What we can do instead is to prepare a dummy row:

Suggested change
row_to_html(columns) # Validate the format is correct, including links
# Validate the format is correct, including links
dummy_row = list(columns)
if links:
dummy_row[columns.index(id_col)] = 1
row_to_html(dummy_row)

return report_with_summary(summary=summary, details="", category=category)

disclaimer = "The total number of affected records is {}. ".format(total) if total else ""
total = len(data) if total is None else total
limit = min(limit, total) if limit is not None else total
if total > limit:
disclaimer += "This list is showing the first {} records.".format(limit)

rows = "<ul>\n" + "\n".join([row_to_html(row) for row in data[:limit]]) + "\n</ul>"
return report_with_summary(summary, "<i>{}</i>{}".format(disclaimer, rows), category)


def announce_release_note(cr):
filepath = os.path.join(os.path.dirname(__file__), "release-note.xml")
with open(filepath, "rb") as fp:
Expand Down