diff --git a/src/base/tests/test_util.py b/src/base/tests/test_util.py
index 325368fe1..30fb830ba 100644
--- a/src/base/tests/test_util.py
+++ b/src/base/tests/test_util.py
@@ -2677,3 +2677,63 @@ def test_assert_updated_combo(self):
):
p2.city = "Niflheim"
util.flush(p2)
+
+
+class TestReportUtils(UnitTestCase):
+ @parametrize(
+ [
+ (
+ "Simple test with minimal arguments and no data.",
+ [],
+ ("id", "name"),
+ "Partner {name} has id {id}",
+ None,
+ None,
+ 100,
+ "Other",
+ "Simple test with minimal arguments and no data.",
+ ),
+ (
+ "Testing links.",
+ [],
+ ("id", "name"),
+ "Partner {partner_link}.",
+ {"partner_link": ("res.partner", "id", "name")},
+ None,
+ 100,
+ "Other",
+ "Testing links.",
+ ),
+ (
+ "Test with minimal data.",
+ [(1, "Partner One")],
+ ("id", "name"),
+ "Partner {partner_link}.",
+ {"partner_link": ("res.partner", "id", "name")},
+ None,
+ 100,
+ "Other",
+ "Test with minimal data.The total number of affected records is 1. ",
+ ),
+ (
+ "Test with limited data.",
+ [(1, "Partner One"), (2, "Partner Two"), (3, "Partner Three")],
+ ("id", "name"),
+ "Partner {partner_link}.",
+ {"partner_link": ("res.partner", "id", "name")},
+ None,
+ 2,
+ "Other",
+ "Test with limited data.The total number of affected records is 3. This list is limited to 2 records. ",
+ ),
+ ]
+ )
+ def test_report_with_list(self, summary, data, columns, row_format, links, total, limit, category, expected):
+ self.assertEqual(
+ util.report_with_list(summary, data, columns, row_format, links, total, limit, category), expected
+ )
diff --git a/src/util/report.py b/src/util/report.py
index 2dd965aba..c0968545b 100644
--- a/src/util/report.py
+++ b/src/util/report.py
@@ -106,7 +106,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))
@@ -126,6 +126,89 @@ 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 = (
+ "{}{} ".format(summary, details)
+ if details
+ else "{}".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 the `total` number would be
+ included in the report as well.
+ :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()
+ }
+ )
+ return "
{}".format(row_format.format(**row_dict))
+
+ if not data:
+ row_to_html(columns) # Validate the format is correct, including links
+ return report_with_summary(summary=summary, details="", category=category)
+
+ limit = min(limit, len(data))
+ total = len(data) if total is None else total
+ disclaimer = "The total number of affected records is {}.".format(total)
+ if total > limit:
+ disclaimer += " This list is limited to {} records.".format(limit)
+
+ rows = "\n" + "\n".join([row_to_html(row) for row in data[:limit]]) + "\n
"
+ return report_with_summary(summary, "{}{}".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: