openupgradelib/openupgrade_160.py
# Copyright 2022 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
"""This module provides simple tools for OpenUpgrade migration, specific for
the >=16.0 migration.
"""
import itertools
import logging
from itertools import product
from psycopg2 import sql
from psycopg2.extras import Json
from odoo.tools.translate import _get_translation_upgrade_queries
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,
replace_html_replacement_class_rp_by_inline_shortcut as _class_rp_by_inline,
)
logger = logging.getLogger("OpenUpgrade")
logger.setLevel(logging.DEBUG)
def migrate_translations_to_jsonb(env, fields_spec):
"""
In Odoo 16, translated fields no longer use the model ir.translation.
Instead they store all their values into jsonb columns
in the model's table.
See https://github.com/odoo/odoo/pull/97692 for more details.
Odoo provides a method _get_translation_upgrade_queries returning queries
to execute to migrate all the translations of a particular field.
The present openupgrade method executes the provided queries
on table _ir_translation if exists (when ir_translation table was renamed
by Odoo's migration scripts) or on table ir_translation (if module was
migrated by OCA).
This should be called in a post-migration script of the module
that contains the definition of the translatable field.
:param fields_spec: list of tuples of (model name, field name)
"""
initial_translation_tables = None
if table_exists(env.cr, "_ir_translation"):
initial_translation_tables = "_ir_translation"
elif table_exists(env.cr, "ir_translation"):
initial_translation_tables = "ir_translation"
if initial_translation_tables:
for model, field_name in fields_spec:
field = env[model]._fields[field_name]
for query in itertools.chain.from_iterable(
_get_translation_upgrade_queries(env.cr, field)
):
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",
)
_MARGIN_PADDING_ELEMENT_REPLACEMENT = (
("pl", "ps"),
("ml", "ms"),
("pr", "pr"),
("mr", "me"),
)
_MARGIN_PADDING_SIZE = ("0", "1", "2", "3", "4", "5", "auto")
_MARGIN_PADDING = ("sm", "lg")
# 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"),
# Special case where text-justify no longer exist
_class_rp_by_inline(
selector="//*[contains(@class, 'text-justify')]",
selector_mode="xpath",
class_rp_by_inline={"text-justify": ["text-align: justify"]},
),
_r(class_rm="text-justify", class_add=""),
# 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("mr", "me"),
# For stub like pl-0 -> ps-0
*(
_r("%s-%s" % (t4, size), "%s-%s" % (t5, size))
for (t4, t5), size in product(
_MARGIN_PADDING_ELEMENT_REPLACEMENT, _MARGIN_PADDING_SIZE
)
),
# For stub like ml-sm-1 -> ms-sm-1
*(
_r("%s-%s-%s" % (t4, context, size), "%s-%s-%s" % (t5, context, size))
for (t4, t5), context, size in product(
_MARGIN_PADDING_ELEMENT_REPLACEMENT, _MARGIN_PADDING, _MARGIN_PADDING_SIZE
)
),
# Forms
_r("custom-control", "form-control"),
_r("custom-checkbox", "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-select-sm", "form-select-sm"),
_r("custom-select-lg", "form-select-lg"),
_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-1"),
_r("rounded-lg", "rounded-3"),
# 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-content]",
selector_mode="xpath",
attr_rp={"data-content": "data-bs-content"},
),
_attr_replace(
selector="//*[@data-placement]",
selector_mode="xpath",
attr_rp={"data-placement": "data-bs-placement"},
),
)
# 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, "!=", "<p><br></p>")]
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.
"""
query = "SELECT id, {field} FROM {table}"
format_query_args = {"field": sql.Identifier(field), "table": sql.Identifier(table)}
params = ()
if ids:
query = f"{query} WHERE id IN %s"
params = (tuple(ids),)
cr.execute(sql.SQL(query).format(**format_query_args), params)
for id_, old_content in cr.fetchall():
if type(old_content) == dict:
new_content = Json(
{
key: convert_string_bootstrap_4to5(value)
for key, value in old_content.items()
}
)
else:
new_content = convert_string_bootstrap_4to5(old_content)
if old_content != new_content:
cr.execute(
sql.SQL("UPDATE {table} SET {field} = %s WHERE id = %s").format(
**format_query_args
),
(
new_content,
id_,
),
)
def fill_analytic_distribution(
env,
table,
m2m_rel,
m2m_column1,
m2m_column2="account_analytic_tag_id",
column="analytic_distribution",
analytic_account_column="analytic_account_id",
):
"""Convert v15 analytic tags with distributions to v16 analytic distributions.
:param table: Name of the main table (eg. sale_order_line...).
:param m2m_rel: Name of the table for the m2m field that stores v15 analytic tags
(eg. account_analytic_tag_sale_order_line_rel)
:param m2m_column1: Name of the column in the m2m table storing the ID of the
record of the main table (eg. sale_order_line_id).
:param m2m_column2: (Optional) Name of the column in the m2m table storing the ID of
the record of the analytic tag. By default, it's "account_analytic_tag_id".
:param column: (Optional) Name of the column in the main table for storing the new
analytic distribution. By default, it's "analytic_distribution".
:param analytic_account_column: (Optional) Name of the column in the main table for
storing the old analytic account. By default, it's analytic_account_id.
"""
logged_query(
env.cr,
f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} jsonb",
)
logged_query(
env.cr,
f"""
WITH distribution_data AS (
WITH sub AS (
SELECT
all_line_data.line_id,
all_line_data.analytic_account_id,
SUM(all_line_data.percentage) AS percentage
FROM (
SELECT
line.id AS line_id,
account.id AS analytic_account_id,
100 AS percentage
FROM {table} line
JOIN account_analytic_account account
ON account.id = line.{analytic_account_column}
WHERE line.{analytic_account_column} IS NOT NULL
UNION ALL
SELECT
line.id AS line_id,
dist.account_id AS analytic_account_id,
dist.percentage AS percentage
FROM {table} line
JOIN {m2m_rel} tag_rel
ON tag_rel.{m2m_column1} = line.id
JOIN account_analytic_distribution dist
ON dist.tag_id = tag_rel.{m2m_column2}
JOIN account_analytic_tag aat
ON aat.id = tag_rel.{m2m_column2}
WHERE aat.active_analytic_distribution = true
) AS all_line_data
GROUP BY all_line_data.line_id, all_line_data.analytic_account_id
)
SELECT sub.line_id,
jsonb_object_agg(sub.analytic_account_id::text, sub.percentage)
AS analytic_distribution
FROM sub
GROUP BY sub.line_id
)
UPDATE {table} line
SET {column} = dist.analytic_distribution
FROM distribution_data dist WHERE line.id = dist.line_id
""",
)