diff --git a/openupgradelib/openupgrade_160.py b/openupgradelib/openupgrade_160.py index c64ede5e..bd5db368 100644 --- a/openupgradelib/openupgrade_160.py +++ b/openupgradelib/openupgrade_160.py @@ -5,10 +5,22 @@ the >=16.0 migration. """ import itertools +import logging +from itertools import product + +from psycopg2.extensions import AsIs from odoo.tools.translate import _get_translation_upgrade_queries -from .openupgrade import logged_query, table_exists +from .openupgrade import logged_query, table_exists, update_field_multilang +from .openupgrade_tools import ( + convert_html_fragment, + convert_html_replacement_class_shortcut as _r, + replace_html_replacement_attr_shortcut as _attr_replace, +) + +logger = logging.getLogger("OpenUpgrade") +logger.setLevel(logging.DEBUG) def migrate_translations_to_jsonb(env, fields_spec): @@ -45,3 +57,320 @@ def migrate_translations_to_jsonb(env, fields_spec): if initial_translation_tables == "ir_translation": query = query.replace("_ir_translation", "ir_translation") logged_query(env.cr, query) + + +_BADGE_CONTEXTS = ( + "secondary", + "primary", + "success", + "info", + "warning", + "danger", +) + +_RTL_REPLACEMENT_CONTEXT = ( + ("left", "start"), + ("right", "end"), +) + +_RTL_REPLACEMENT_ELEMENT = ( + "text", + "float", + "border", + "border-top", + "border-bottom", + "rounded", +) + +# These replacements are from standard Bootstrap 4 to 5 +_BS5_REPLACEMENTS = ( + # Grid stuff + _r(class_rm="no-gutters", class_add="g-0"), + _r(class_rm="media", class_add="d-flex"), + _r("media-body", "flex-grow-1"), + # Content, Reboot, etc + _r("thead-light", "table-light"), + _r("thead-dark", "table-dark"), + _r( + "text-justify", "text-center" + ), # actually boostrap 5 only drop without any replacements + # RTL + *( + _r("%s-%s" % (elem, t4), "%s-%s" % (elem, t5)) + for (t4, t5), elem in product( + _RTL_REPLACEMENT_CONTEXT, _RTL_REPLACEMENT_ELEMENT + ) + ), + _r("pl", "ps"), + _r("pr", "pe"), + _r("ml", "ms"), + _r("pr", "me"), + # Forms + _r("custom-control", "form-control"), + _r("custom-check", "form-check"), + _r("custom-control-input", "form-check-input"), + _r("custom-control-label", "form-check-label"), + _r("custom-switch", "form-switch"), + _r("custom-select", "form-select"), + _r("custom-range", "form-range"), + _r("form-control-file", "form-control"), + _r("form-control-range", "form-control"), + _r(selector="span.input-group-append", class_rm="input-group-append"), + _r( + selector="div.input-group-append", + class_rm="input-group-append", + class_add="input-group-text", + ), + _r(selector="div.input-group-prepend", class_rm="input-group-prepend"), + _r( + selector="span.input-group-prepend", + class_rm="input-group-prepend", + ), + _r(class_rm="form-row", class_add="row"), + _r(selector=".form-inline", class_rm="form-inline"), + # Badges + *( + _r(class_rm="badge-%s" % badge_context, class_add="bg-%s" % badge_context) + for badge_context in _BADGE_CONTEXTS + ), + _r(class_rm="badge-pill", class_add="rounded-pill"), + # Close button + _r(class_rm="close", class_add="btn-close"), + # Utilities + _r("text-monospace", "font-monospace"), + _r("text-hide", "visually-hidden"), + _r("font-weight-normal", "fw-normal"), + _r("font-weight-bold", "fw-bold"), + _r("font-weight-lighter", "fw-lighter"), + _r("font-weight-bolder", "fw-bolder"), + _r("font-weight-medium", "fw-medium"), + _r("font-weight-normal", "fw-normal"), + _r("font-weight-normal", "fw-normal"), + _r("font-italic", "fst-italic"), + _r("font-normal", "fst-normal"), + _r("rounded-sm", "rounded-0"), + _r("rounded-lg", "rounded-1"), + # Helpers + _r(selector="embed-responsive-item", class_rm="embed-responsive-item"), + _r("sr-only", "visually-hidden"), + _r("sr-only-focusable", "visually-hidden-focusable"), + # JavaScript + _attr_replace( + selector="//*[@data-ride]", + selector_mode="xpath", + attr_rp={"data-ride": "data-bs-ride"}, + ), + _attr_replace( + selector="//*[@data-interval]", + selector_mode="xpath", + attr_rp={"data-interval": "data-bs-interval"}, + ), + _attr_replace( + selector="//*[@data-toggle]", + selector_mode="xpath", + attr_rp={"data-toggle": "data-bs-toggle"}, + ), + _attr_replace( + selector="//*[@data-dismiss]", + selector_mode="xpath", + attr_rp={"data-dismiss": "data-bs-dismiss"}, + ), + _attr_replace( + selector="//*[@data-trigger]", + selector_mode="xpath", + attr_rp={"data-trigger": "data-bs-trigger"}, + ), + _attr_replace( + selector="//*[@data-target]", + selector_mode="xpath", + attr_rp={"data-target": "data-bs-target"}, + ), + _attr_replace( + selector="//*[@data-spy]", + selector_mode="xpath", + attr_rp={"data-spy": "data-bs-spy"}, + ), + _attr_replace( + selector="//*[@data-display]", + selector_mode="xpath", + attr_rp={"data-display": "data-bs-display"}, + ), + _attr_replace( + selector="//*[@data-backdrop]", + selector_mode="xpath", + attr_rp={"data-backdrop": "data-bs-backdrop"}, + ), + _attr_replace( + selector="//*[@data-original-title]", + selector_mode="xpath", + attr_rp={"data-original-title": "data-bs-original-title"}, + ), + _attr_replace( + selector="//*[@data-template]", + selector_mode="xpath", + attr_rp={"data-template": "data-bs-template"}, + ), + _attr_replace( + selector="//*[@data-html]", + selector_mode="xpath", + attr_rp={"data-html": "data-bs-html"}, + ), + _attr_replace( + selector="//*[@data-slide]", + selector_mode="xpath", + attr_rp={"data-slide": "data-bs-slide"}, + ), + _attr_replace( + selector="//*[@data-slide-to]", + selector_mode="xpath", + attr_rp={"data-slide-to": "data-bs-slide-to"}, + ), + _attr_replace( + selector="//*[@data-parent]", + selector_mode="xpath", + attr_rp={"data-parent": "data-bs-parent"}, + ), + _attr_replace( + selector="//*[@data-focus]", + selector_mode="xpath", + attr_rp={"data-focus": "data-bs-focus"}, + ), + _attr_replace( + selector="//*[@data-parent]", + selector_mode="xpath", + attr_rp={"data-parent": "data-bs-parent"}, + ), + _attr_replace( + selector="//*[@data-content]", + selector_mode="xpath", + attr_rp={"data-content": "data-bs-content"}, + ), + _attr_replace( + selector="//*[@data-parent]", + selector_mode="xpath", + attr_rp={"data-parent": "data-bs-parent"}, + ), +) + +# These replacements are specific for Odoo v15 to v16 +_ODOO16_REPLACEMENTS = ( + # Form + _r(class_rm="form-group", class_add="mb-3"), + # Helpers + _r(selector="embed-responsive-16by9", class_rm="embed-responsive"), + _r("embed-responsive-16by9", "ratio ratio-16x9"), + # Javascript + _attr_replace( + selector="//*[@data-keyboard]", + selector_mode="xpath", + attr_rp={"data-keyboard": "data-bs-keyboard"}, + ), +) + +ALL_REPLACEMENTS = _BS5_REPLACEMENTS + _ODOO16_REPLACEMENTS + + +def convert_string_bootstrap_4to5(html_string, pretty_print=True): + """Convert an HTML string from Bootstrap 4 to 5. + + :param str html_string: + Raw HTML fragment to convert. + + :param bool pretty_print: + Indicate if you wish to return the HTML pretty formatted. + + :return str: + Raw HTML fragment converted. + """ + if not html_string: + return html_string + try: + return convert_html_fragment( + html_string, + ALL_REPLACEMENTS, + pretty_print, + ) + except Exception: + logger.error("Error converting string BS4 to BS5:\n%s" % html_string) + raise + + +def convert_field_bootstrap_4to5( + env, model_name, field_name, domain=None, method="orm" +): + """This converts all the values for the given model and field, being + able to restrict to a domain of affected records. + + :param env: Odoo environment. + :param model_name: Name of the model that contains the field. + :param field_name: Name of the field that contains the BS3 HTML content. + :param domain: Optional domain for filtering records in the model + :param method: 'orm' (default) for using ORM; 'sql' for avoiding problems + with extra triggers in ORM. + """ + assert method in {"orm", "sql"} + if method == "orm": + return _convert_field_bootstrap_4to5_orm( + env, + model_name, + field_name, + domain, + ) + records = env[model_name].search(domain or []) + return _convert_field_bootstrap_4to5_sql( + env.cr, + records._table, + field_name, + ) + + +def _convert_field_bootstrap_4to5_orm(env, model_name, field_name, domain=None): + """Convert a field from Bootstrap 4 to 5, using Odoo ORM. + + :param odoo.api.Environment env: Environment to use. + :param str model_name: Model to update. + :param str field_name: Field to convert in that model. + :param domain list: Domain to restrict conversion. + """ + domain = domain or [(field_name, "!=", False), (field_name, "!=", "


")] + records = env[model_name].search(domain) + update_field_multilang( + records, + field_name, + lambda old, *a, **k: convert_string_bootstrap_4to5(old), + ) + + +def _convert_field_bootstrap_4to5_sql(cr, table, field, ids=None): + """Convert a field from Bootstrap 4 to 5, using raw SQL queries. + + TODO Support multilang fields. + + :param odoo.sql_db.Cursor cr: + Database cursor. + + :param str table: + Table name. + + :param str field: + Field name, which should contain HTML content. + + :param list ids: + List of IDs, to restrict operation to them. + """ + sql = "SELECT id, %s FROM %s " % (field, table) + params = () + if ids: + sql += "WHERE id IN %s" + params = (ids,) + cr.execute(sql, params) + for id_, old_content in cr.fetchall(): + new_content = convert_string_bootstrap_4to5(old_content) + if old_content != new_content: + cr.execute( + "UPDATE %s SET %s = %s WHERE id = %s", + AsIs(table), + AsIs(field), + new_content, + id_, + ) diff --git a/openupgradelib/openupgrade_tools.py b/openupgradelib/openupgrade_tools.py index 99c9e448..260daa6d 100644 --- a/openupgradelib/openupgrade_tools.py +++ b/openupgradelib/openupgrade_tools.py @@ -104,6 +104,7 @@ def convert_xml_node( style_rm=frozenset(), tag="", wrap="", + attr_rp=None, ): """Apply conversions to an XML node. @@ -157,9 +158,14 @@ def convert_xml_node( :param str wrap: XML element that will wrap the :param:`node`. + + :param dict attr_rp: + Specify a dict of attribute to place from old to the new one + Ex: {"data-toggle": "data-bs-togle"} (typical case when convert BS4 to BS5 in odoo 16) """ # Fix params attr_add = attr_add or {} + attr_rp = attr_rp or {} class_add = set(class_add.split()) class_rm = set(class_rm.split()) style_add = style_add or {} @@ -180,6 +186,7 @@ def convert_xml_node( _call = lambda v: v(**originals) if callable(v) else v # noqa: E731 attr_add = _call(attr_add) attr_rm = _call(attr_rm) + attr_rp = _call(attr_rp) class_add = _call(class_add) class_rm = _call(class_rm) style_add = _call(style_add) @@ -187,7 +194,10 @@ def convert_xml_node( tag = _call(tag) wrap = _call(wrap) # Patch node attributes - if attr_add or attr_rm: + if attr_add or attr_rm or attr_rp: + for key, value in attr_rp.items(): + if key in node.attrib: + node.attrib[value] = node.attrib.pop(key, None) for key in attr_rm: node.attrib.pop(key, None) for key, value in attr_add.items(): @@ -247,3 +257,27 @@ def convert_html_replacement_class_shortcut(class_rm="", class_add="", **kwargs) } ) return kwargs + + +def replace_html_replacement_attr_shortcut(attr_rp="", **kwargs): + """Shortcut to replace an attribute spec. + + :param dict attr_rp: + EX: {'data-toggle': 'data-bs-toggle'} + Where the 'key' is the attribute will be replaced by the 'value' + + :return dict: + Generated spec, to be included in a list of replacements to be + passed to :meth:`convert_xml_fragment`. + """ + + # Disallow selector to be empty + assert "selector" in kwargs and kwargs["selector"] != "" + # Also to be able to get exact element that have that attribute need selector_mode xpath + assert "selector_mode" in kwargs and kwargs["selector_mode"] == "xpath" + kwargs.update( + { + "attr_rp": attr_rp, + } + ) + return kwargs