diff --git a/src/base/tests/test_util.py b/src/base/tests/test_util.py index 91ebc1bc3..2bd24c8c6 100644 --- a/src/base/tests/test_util.py +++ b/src/base/tests/test_util.py @@ -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}.", + ), + "Simple test with minimal arguments and no data.", + ) + + 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")}, + ), + "Testing links.", + ) + + 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&model=res.partner&action=&id=1" + ) + expected = ( + "Test with minimal data.
" + ).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&model=res.partner&action=&id=1" + href2 = "/web?debug=1#view_type=form&model=res.partner&action=&id=2" + expected = ( + "Test with limited data.
The total number of affected records is 3. This list is showing the first 2 records.
" + ).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&model=res.partner&action=&id=1" + href2 = "/web?debug=1#view_type=form&model=res.partner&action=&id=2" + href3 = "/web?debug=1#view_type=form&model=res.partner&action=&id=3" + expected = ( + "Test with limitless data.
" + ).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, + ) diff --git a/src/util/fields.py b/src/util/fields.py index 65cfcb507..3d64d2c79 100644 --- a/src/util/fields.py +++ b/src/util/fields.py @@ -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: @@ -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( - "
  • {}
  • ".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("{}".format(m) for m in only_models) + total = cr.rowcount + if total: + summary = ( + "{model_text}: the field {old} has been renamed to {new}. " + "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"): @@ -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( - "
  • {}
  • ".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 {old} has been renamed to {new}. " + "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("{}".format(m) for m in only_models) - add_to_migration_reports( - """ -
    - - {model_text}: the field {old} has been renamed to {new}. 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. - - -
    - """.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: diff --git a/src/util/report.py b/src/util/report.py index b6de3e3f6..1ce372135 100644 --- a/src/util/report.py +++ b/src/util/report.py @@ -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)) @@ -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 = ( + "{}
    {}
    ".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 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() + } + ) + row_dict = {col: html_escape(val) for col, val in row_dict.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) + + 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 = "" + 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: