diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f61aab066af..9c16759d0f5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,7 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
+ ^views_migration_17/|
# END NOT INSTALLABLE ADDONS
# Files and folders generated by bots, to avoid loops
^setup/|/static/description/index\.html$|
diff --git a/README.md b/README.md
index 0df69c9d4d9..88aa37c8ab9 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,13 @@ addon | version | maintainers | summary
--- | --- | --- | ---
[module_change_auto_install](module_change_auto_install/) | 17.0.1.0.0 | [![legalsylvain](https://github.com/legalsylvain.png?size=30px)](https://github.com/legalsylvain) | Customize auto installables modules by configuration
+
+Unported addons
+---------------
+addon | version | maintainers | summary
+--- | --- | --- | ---
+[views_migration_17](views_migration_17/) | 17.0.1.0.0 (unported) | | Views Migration to v17
+
[//]: # (end addons)
diff --git a/views_migration_17/README.rst b/views_migration_17/README.rst
new file mode 100644
index 00000000000..759c9004efa
--- /dev/null
+++ b/views_migration_17/README.rst
@@ -0,0 +1,56 @@
+.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+.. image:: https://img.shields.io/badge/python-3.6-blue.svg
+ :alt: Python support: 3.6
+.. image:: https://app.travis-ci.com/OCA/odoo-module-migrator.svg?branch=master
+ :target: https://app.travis-ci.com/OCA/odoo-module-migrator
+
+====================
+Views-migration-v17
+====================
+
+``views-migration-v17`` is a odoo server mode module that allows you to automatically migrate the views of a Odoo module versión <= v16 to v17 .
+
+For example::
+
+
+
+
+
+To::
+
+
+
+
+
+
+Usage
+=====
+
+This module is not installable, to use this module, you need to:
+
+1. Run odoo with this module as a server module:
+
+.. code-block:: shell
+
+ odoo -d DATABASE_NAME -i MODULE_TO_MIGRATE --load=base,web,views_migration_17 --stop-after-init
+
+
+2. If success the modifications will be in the source code of your module.
+
+
+Credits
+=======
+
+Authors
+-------
+* ADHOC SA
+
+
+Contributors
+------------
+* `ADHOC SA `_:
+
+ * Juan José Scarafía
+ * Bruno Zanotti
diff --git a/views_migration_17/__init__.py b/views_migration_17/__init__.py
new file mode 100644
index 00000000000..264d7270d55
--- /dev/null
+++ b/views_migration_17/__init__.py
@@ -0,0 +1,18 @@
+from . import patch_xml_import # patch xml_import so that view is fixed
+
+# patch vies so that they don't break
+from odoo.addons.base.models.ir_ui_view import View
+
+
+_original_check_xml = View._check_xml
+
+
+def _check_xml(self):
+ # TODO we should check exeception is due to the expected error
+ try:
+ _original_check_xml
+ except Exception:
+ pass
+
+
+View._check_xml = _check_xml
diff --git a/views_migration_17/__manifest__.py b/views_migration_17/__manifest__.py
new file mode 100644
index 00000000000..ff0ac6e9049
--- /dev/null
+++ b/views_migration_17/__manifest__.py
@@ -0,0 +1,23 @@
+{
+ "name": "Views Migration to v17",
+ "version": "17.0.1.0.0",
+ "author": "ODOO SA,ADHOC SA,Odoo Community Association (OCA)",
+ "description": """
+Patch modules views related to this change https://github.com/odoo/odoo/pull/104741
+The script is taken from this comment (https://github.com/odoo/odoo/pull/104741#issuecomment-1794616832) on same PR
+To run it:
+1. Add module as server wide module.
+2. Run odoo server installing or upgrading target module.
+
+For eg: odoo -i upgrade_analysis -d upgrade_analysis --load=base,web,views_migration_17
+""",
+ "website": "https://github.com/OCA/server-tools",
+ "license": "AGPL-3",
+ "depends": [
+ "base",
+ ],
+ "data": [],
+ "installable": False,
+ "auto_install": False,
+ "application": False,
+}
diff --git a/views_migration_17/patch_xml_import.py b/views_migration_17/patch_xml_import.py
new file mode 100644
index 00000000000..cd5b1472ce6
--- /dev/null
+++ b/views_migration_17/patch_xml_import.py
@@ -0,0 +1,1556 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import ast
+import logging
+import re
+
+from lxml import etree
+
+from odoo.exceptions import ValidationError
+from odoo.tools import mute_logger
+from odoo.tools.misc import file_open
+from odoo.tools.template_inheritance import locate_node
+
+_logger = logging.getLogger(__name__)
+
+
+from odoo.tools.convert import xml_import
+
+original_tag_record = xml_import._tag_record
+
+
+def new_tag_record(self, rec, extra_vals=None):
+ rec_model = rec.get("model")
+ if rec_model == "ir.ui.view":
+ _convert_ir_ui_view_modifiers(self, rec, extra_vals=extra_vals)
+ return original_tag_record(self, rec, extra_vals=extra_vals)
+
+
+xml_import._tag_record = new_tag_record
+
+
+def _convert_ir_ui_view_modifiers(self, record_node, extra_vals=None):
+ rec_id = record_node.get("id", "")
+ f_model = record_node.find('field[@name="model"]')
+ f_type = record_node.find('field[@name="type"]')
+ f_inherit = record_node.find('field[@name="inherit_id"]')
+ f_arch = record_node.find('field[@name="arch"]')
+ root = f_arch if f_arch is not None else record_node
+
+ ref = f"{rec_id} ({self.xml_filename})"
+
+ try:
+ data_id = f_inherit is not None and f_inherit.get("ref")
+ inherit = None
+ if data_id:
+ if "." not in data_id:
+ data_id = f"{self.module}.{data_id}"
+ inherit = self.env.ref(data_id)
+
+ model_name = f_model is not None and f_model.text
+ if not model_name and inherit:
+ model_name = inherit.model
+ if not model_name:
+ return
+
+ view_type = f_type is not None and f_type.text or root[0].tag
+ if inherit:
+ view_type = inherit.type
+
+ if view_type not in ("kanban", "tree", "form", "calendar", "setting", "search"):
+ return
+
+ # load previous arch
+ arch = None
+ previous_xml = file_open(self.xml_filename, "r").read()
+ match = re.search(
+ rf"""(]*id=['"]{rec_id}['"][^>]*>(?:[^<]|<(?!/record>))+)""",
+ previous_xml,
+ )
+ if not match:
+ _logger.error(f"Can not found {rec_id!r} from {self.xml_filename}")
+ return
+ record_xml = match.group(1)
+
+ match = re.search(
+ r"""(]*name=["']arch["'][^>]*>((.|\n)+))""", record_xml
+ )
+ if not match:
+ _logger.error(f"Can not found arch of {rec_id!r} from {self.xml_filename}")
+ return
+ arch = match.group(2).strip()
+
+ # load inherited arch
+ inherited_root = inherit and etree.fromstring(inherit.get_combined_arch())
+
+ head = False
+ added_data = False
+ arch_clean = arch
+ if arch_clean.startswith(""):
+ head, arch_clean = arch_clean.split("\n", 1)
+ if not arch_clean.startswith(""):
+ added_data = True
+ arch_clean = f"{arch_clean}"
+ root_content = etree.fromstring(arch_clean)
+ model = self.env[model_name]
+
+ try:
+ arch_result = convert_template_modifiers(
+ self.env,
+ arch_clean,
+ root_content,
+ model,
+ view_type,
+ ref,
+ inherited_root=inherited_root,
+ )
+ except Exception as e:
+ _logger.error(f"Can not convert: {rec_id!r} from {self.xml_filename}\n{e}")
+ return
+
+ if re.sub(rf"(\n| )*{reg_comment}(\n| )*", "", arch_result) == "":
+ _logger.error(
+ f"No uncommented element found: {rec_id!r} from {self.xml_filename}"
+ )
+ arch_result = (
+ arch_result[:6]
+ + ''
+ + arch_result[6:]
+ )
+
+ if added_data:
+ arch_result = arch_result[6:-7]
+ if head:
+ arch_result = head + arch_result
+
+ if arch_result != arch:
+ if added_data:
+ while len(f_arch):
+ f_arch.remove(f_arch[0])
+ for n in root_content:
+ f_arch.append(n)
+ f_arch.text = root_content.text
+
+ new_xml = previous_xml.replace(arch, arch_result)
+ with file_open(self.xml_filename, "w") as file:
+ file.write(new_xml)
+ try:
+ # test file before save
+ etree.fromstring(new_xml.encode())
+ except Exception as e:
+ _logger.error(
+ f"Wrong view conversion in {rec_id!r} from {self.xml_filename}\n\n{arch}\n\n{e}"
+ )
+ return
+
+ except Exception as e:
+ _logger.error("FAIL ! %s\n%s", ref, e)
+
+
+import itertools
+
+from odoo.osv.expression import (
+ AND_OPERATOR,
+ DOMAIN_OPERATORS,
+ FALSE_LEAF,
+ NOT_OPERATOR,
+ OR_OPERATOR,
+ TERM_OPERATORS,
+ TRUE_LEAF,
+ distribute_not,
+ normalize_domain,
+)
+from odoo.tools import apply_inheritance_specs, locate_node, mute_logger
+
+# from odoo import tools
+from odoo.tools.misc import str2bool, unique
+from odoo.tools.safe_eval import _BUILTINS
+from odoo.tools.view_validation import (
+ _get_expression_contextual_values,
+ get_domain_value_names,
+ get_expression_field_names,
+)
+
+VALID_TERM_OPERATORS = TERM_OPERATORS + ("<>", "==")
+AST_OP_TO_STR = {
+ ast.Eq: "==",
+ ast.NotEq: "!=",
+ ast.Lt: "<",
+ ast.LtE: "<=",
+ ast.Gt: ">",
+ ast.GtE: ">=",
+ ast.Is: "is",
+ ast.IsNot: "is not",
+ ast.In: "in",
+ ast.NotIn: "not in",
+ ast.Add: "+",
+ ast.Sub: "-",
+ ast.Mult: "*",
+ ast.Div: "/",
+ ast.FloorDiv: "//",
+ ast.Mod: "%",
+ ast.Pow: "^",
+}
+
+
+class InvalidDomainError(ValueError):
+ """Domain can contain only '!', '&', '|', tuples or expression whose returns boolean"""
+
+
+#######################################################################
+
+
+def convert_template_modifiers(
+ env, arch, root, rec_model, view_type, ref, inherited_root=None
+):
+ """Convert old syntax (attrs, states...) into new modifiers syntax"""
+ result = arch
+ if not arch.startswith(""):
+ raise ValueError(
+ f"Wrong formating for view conversion. Arch must be wrapped with : {ref!r}\n{arch}"
+ )
+
+ if inherited_root is None: # this is why it must be False
+ result = convert_basic_view(arch, root, env, rec_model, view_type, ref)
+ else:
+ result = convert_inherit_view(
+ arch, root, env, rec_model, view_type, ref, inherited_root
+ )
+
+ if not result.startswith(""):
+ raise ValueError(
+ f"View conversion failed. Result should had been wrapped with : {ref!r}\n{result}"
+ )
+
+ root_result = etree.fromstring(result.encode())
+
+ # Check for incomplete conversion, those attributes should had been removed by
+ # convert_basic_view and convert_inherit_view. In case there are some left
+ # just log an error but keep the converted view in the database/file.
+ for item in root_result.findall('.//attribute[@name="states"]'):
+ xml = etree.tostring(item, encoding="unicode")
+ _logger.error('Incomplete view conversion ("states"): %r\n%s', ref, xml)
+ for item in root_result.findall('.//attribute[@name="attrs"]'):
+ xml = etree.tostring(item, encoding="unicode")
+ _logger.error('Incomplete view conversion ("attrs"): %r\n%s', ref, xml)
+ for item in root_result.findall(".//*[@attrs]"):
+ xml = etree.tostring(item, encoding="unicode")
+ _logger.error('Incomplete view conversion ("attrs"): %r\n%s', ref, xml)
+ for item in root_result.findall(".//*[@states]"):
+ xml = etree.tostring(item, encoding="unicode")
+ _logger.error('Incomplete view conversion ("states"): %r\n%s', ref, xml)
+
+ return result
+
+
+def convert_basic_view(arch, root, env, model, view_type, ref):
+ updated_nodes, _analysed_nodes = convert_node_modifiers_inplace(
+ root, env, model, view_type, ref
+ )
+ if not updated_nodes:
+ return arch
+ return replace_and_keep_indent(root, arch, ref)
+
+
+def convert_inherit_view(arch, root, env, model, view_type, ref, inherited_root):
+ updated = False
+ result = arch
+
+ def get_target(spec):
+ target_node = None
+
+ try:
+ with mute_logger("odoo.tools.template_inheritance"):
+ target_node = locate_node(inherited_root, spec)
+ # target can be None without error
+ except Exception:
+ pass
+
+ if target_node is None:
+ clone = etree.tostring(
+ etree.Element(spec.tag, spec.attrib), encoding="unicode"
+ )
+ _logger.info("Target not found for %s with xpath: %s", ref, clone)
+ return None, view_type, model
+
+ parent_view_type = view_type
+ target_model = model
+ parent_f_names = []
+ for p in target_node.iterancestors():
+ if (
+ p.tag == "field" or p.tag == "groupby"
+ ): # subview and groupby in tree view
+ parent_f_names.append(p.get("name"))
+
+ for p in target_node.iterancestors():
+ if p.tag in ("groupby", "header"):
+ # in tree view
+ parent_view_type = "form"
+ break
+ elif p.tag in ("tree", "form", "setting"):
+ parent_view_type = p.tag
+ break
+
+ for name in reversed(parent_f_names):
+ try:
+ field = target_model._fields[name]
+ target_model = env[field.comodel_name]
+ except KeyError:
+ # Model is custom or had been removed. Can convert view without using field python states
+ if name in target_model._fields:
+ _logger.warning(
+ "Unknown model %s. The modifiers may be incompletely converted. %s",
+ target_model._fields[name].comodel_name,
+ ref,
+ )
+ else:
+ _logger.warning(
+ "Unknown field %s on model %s. The modifiers may be incompletely converted. %s",
+ name,
+ target_model,
+ ref,
+ )
+ target_model = None
+ break
+
+ return target_node, parent_view_type, target_model
+
+ specs = []
+ for spec in root:
+ if isinstance(spec.tag, str):
+ if spec.tag == "data":
+ specs.extend(c for c in spec)
+ else:
+ specs.append(spec)
+
+ for spec in specs:
+ spec_xml = get_targeted_xml_content(spec, result)
+
+ if spec.get("position") == "attributes":
+ target_node, parent_view_type, target_model = get_target(spec)
+ updated = convert_inherit_attributes_inplace(
+ spec, target_node, parent_view_type
+ )
+ xml = (
+ etree.tostring(spec, pretty_print=True, encoding="unicode")
+ .replace(""", "'")
+ .strip()
+ )
+ else:
+ _target_node, parent_view_type, target_model = get_target(spec)
+ updated = (
+ convert_node_modifiers_inplace(
+ spec, env, target_model, parent_view_type, ref
+ )[0]
+ or updated
+ )
+ xml = replace_and_keep_indent(spec, spec_xml, ref)
+ try:
+ with mute_logger("odoo.tools.template_inheritance"):
+ inherited_root = apply_inheritance_specs(
+ inherited_root, etree.fromstring(xml)
+ )
+ except (ValueError, etree.XPathSyntaxError, ValidationError):
+ clone = xml.split(">", 1)[0] + ">"
+ if "%(" in clone:
+ _logger.info("Can not apply inheritance: %s\nPath: %r", ref, clone)
+ else:
+ _logger.error("Can not apply inheritance: %s\nPath: %r", ref, clone)
+ # updated = True
+ # xml = xml.replace('--', '- -').replace('--', '- -')
+ # comment = etree.Comment(f' {xml} ')
+ # spec.getparent().replace(spec, comment)
+ # xml = f''
+ except Exception:
+ _logger.error(
+ "Can not apply inheritance: %s\nPath: %r",
+ ref,
+ xml.split(">", 1)[0] + ">",
+ )
+ # updated = True
+ # xml = xml.replace('--', '- -').replace('--', '- -')
+ # comment = etree.Comment(f' {xml} ')
+ # spec.getparent().replace(spec, comment)
+ # xml = f''
+
+ if updated:
+ if spec_xml not in result:
+ _logger.error(
+ "Can not apply inheritance: %s\nPath: %r",
+ ref,
+ xml.split(">", 1)[0] + ">",
+ )
+ else:
+ result = result.replace(spec_xml, xml, 1)
+
+ return result
+
+
+def convert_inherit_attributes_inplace(spec, target_node, view_type):
+ """
+ convert inherit with +
+ The conversion is different if attrs and invisible/readonly/required are modified.
+ (can replace attributes, or use separator " or " to combine with previous)
+
+ migration is idempotent, this eg stay unchanged:
+ (aaa)
+ 0
+ 1
+
+ """
+
+ migrated = False
+ has_change = False
+ items = {}
+ to_remove = set()
+ node = None
+ for attr in ("attrs", "column_invisible", "invisible", "readonly", "required"):
+ nnode = spec.find(f'.//attribute[@name="{attr}"]')
+ if nnode is None:
+ continue
+ to_remove.add(nnode)
+
+ value = nnode.text and nnode.text.strip()
+ if value not in ("True", "False", "0", "1"):
+ node = nnode
+ if nnode.get("separator") or (value and value[0] == "("):
+ # previously migrate
+ migrated = True
+ break
+ if attr == "attrs":
+ try:
+ value = (
+ value
+ and ast.literal_eval(value)
+ or {"invisible": "", "readonly": "", "required": ""}
+ )
+ except Exception as error:
+ raise ValueError(f'Can not convert "attrs": {value!r}') from error
+ elif (
+ attr == "invisible"
+ and view_type == "tree"
+ and (
+ value in ("0", "1", "True", "False")
+ or (
+ value.startswith("context")
+ and " or " not in value
+ and " and " not in value
+ )
+ )
+ ):
+ attr = "column_invisible"
+ items[attr] = value
+
+ if node is None or not items or migrated:
+ return has_change
+
+ index = spec.index(node)
+ is_last = spec[-1] == node
+
+ domain_attrs = items.pop("attrs", {})
+ all_attrs = list(set(items) | set(domain_attrs))
+ all_attrs.sort()
+
+ i = len(all_attrs)
+ next_xml = ""
+
+ for attr in all_attrs:
+ value = items.get(attr)
+ domain = domain_attrs.get(attr, "")
+ attr_value = (
+ domain_to_expression(domain) if isinstance(domain, list) else str(domain)
+ )
+
+ i -= 1
+ elem = etree.Element("attribute", {"name": attr})
+ if i or not is_last:
+ elem.tail = spec.text
+ else:
+ elem.tail = spec[-1].tail
+ spec[-1].tail = spec.text
+
+ if value and attr_value:
+ has_change = True
+ # replace whole expression
+ if value in ("False", "0"):
+ elem.text = attr_value
+ elif value in ("True", "1"):
+ elem.text = value
+ else:
+ elem.text = f"({value}) or ({attr_value})"
+ else:
+ inherited_value = target_node.get(attr) if target_node is not None else None
+ inherited_context = (
+ _get_expression_contextual_values(
+ ast.parse(inherited_value.strip(), mode="eval").body
+ )
+ if inherited_value
+ else set()
+ )
+ res_value = value or attr_value or "False"
+
+ if inherited_context:
+ # replace whole expression if replace record value by record value, or context/parent by context/parent
+ #
+ # is replaced
+ #
+ # =>
+ # will be combined
+ #
+ # =>
+ # logged because human control is necessary
+
+ context = _get_expression_contextual_values(
+ ast.parse(res_value.strip(), mode="eval").body
+ )
+
+ has_record = any(True for v in context if not v.startswith("context."))
+ has_context = any(True for v in context if v.startswith("context."))
+ inherited_has_record = any(
+ True for v in inherited_context if not v.startswith("context.")
+ )
+ inherited_has_context = any(
+ True for v in inherited_context if v.startswith("context.")
+ )
+
+ if (
+ has_record == inherited_has_record
+ and has_context == inherited_has_context
+ ):
+ elem.text = res_value
+ if attr_value:
+ has_change = True
+ elif has_context and not has_record:
+ elem.set("add", res_value)
+ elem.set("separator", " or ")
+ has_change = True
+ elif not inherited_has_record:
+ elem.set("add", res_value)
+ elem.set("separator", " or ")
+ has_change = True
+ elif not value and not attr_value:
+ has_change = True
+ elif res_value in ("0", "False", "1", "True"):
+ elem.text = res_value
+ has_change = True
+ else:
+ elem.set("add", res_value)
+ elem.set("separator", " or ")
+ has_change = True
+ _logger.info(
+ "The migration of attributes inheritance might not be exact: %s",
+ etree.tostring(elem, encoding="unicode"),
+ )
+ elif not value and not attr_value:
+ continue
+ else:
+ elem.text = res_value
+ if attr_value:
+ has_change = True
+
+ spec.insert(index, elem)
+ index += 1
+
+ # remove previous node and xml
+ for node in to_remove:
+ spec.remove(node)
+
+ return has_change
+
+
+def convert_node_modifiers_inplace(root, env, model, view_type, ref):
+ """Convert inplace old syntax (attrs, states...) into new modifiers syntax"""
+ updated_nodes = set()
+ analysed_nodes = set()
+
+ def expr_to_attr(item, py_field_modifiers=None, field=None):
+ if item in analysed_nodes:
+ return
+ analysed_nodes.add(item)
+
+ try:
+ modifiers = extract_node_modifiers(item, view_type, py_field_modifiers)
+ except ValueError as error:
+ if (
+ "country_id != %(base." in error.args[0]
+ or "%(base.lu)d not in account_enabled_tax_country_ids" in error.args[0]
+ ):
+ # Odoo xml file can use %(...)s ref/xmlid, this part is
+ # replaced later by the record id. This code cannot be
+ # parsed into a domain and convert into a expression.
+ # Just skip it.
+ return
+ xml = etree.tostring(item, encoding="unicode")
+ _logger.error(
+ "Invalid modifiers syntax: %s\nError: %s\n%s", ref, error, xml
+ )
+ return
+
+ # apply new modifiers on item only when modified...
+ for attr in ("column_invisible", "invisible", "readonly", "required"):
+ new_py_expr = modifiers.pop(attr, None)
+ old_expr = item.attrib.get(attr)
+
+ if (
+ old_expr == new_py_expr
+ or (old_expr in ("1", "True") and new_py_expr == "True")
+ or (old_expr in ("0", "False") and new_py_expr in ("False", None))
+ ):
+ continue
+
+ if new_py_expr and (
+ new_py_expr != "False"
+ or (attr == "readonly" and field and field.readonly)
+ or (attr == "required" and field and field.required)
+ ):
+ item.attrib[attr] = new_py_expr
+ else:
+ item.attrib.pop(attr, None)
+ updated_nodes.add(item)
+
+ # ... and remove old attributes
+ if item.attrib.pop("states", None):
+ updated_nodes.add(item)
+ if item.attrib.pop("attrs", None):
+ updated_nodes.add(item)
+
+ # they are some modifiers left, some templates are badly storing
+ # options in attrs, then they must be left as is (e.g.: studio
+ # widget, name, ...)
+ if modifiers:
+ item.attrib["attrs"] = repr(modifiers)
+
+ def in_subview(item):
+ for p in item.iterancestors():
+ if p == root:
+ return False
+ if p.tag in ("field", "groupby"):
+ return True
+
+ if model is not None:
+ if view_type == "tree":
+ # groupby from tree target the field as a subview (inside groupby is treated as form)
+ for item in root.findall(".//groupby[@name]"):
+ f_name = item.get("name")
+ field = model._fields[f_name]
+ updated, fnodes = convert_node_modifiers_inplace(
+ item, env, env[field.comodel_name], "form", ref
+ )
+ analysed_nodes.update(fnodes)
+ updated_nodes.update(updated)
+
+ for item in root.findall(".//field[@name]"):
+ if in_subview(item):
+ continue
+
+ if item in analysed_nodes:
+ continue
+
+ # in kanban view, field outside the template should not have modifiers
+ if view_type == "kanban" and item.getparent().tag == "kanban":
+ for attr in (
+ "states",
+ "attrs",
+ "column_invisible",
+ "invisible",
+ "readonly",
+ "required",
+ ):
+ item.attrib.pop(attr, None)
+ continue
+
+ # shortcut for views that do not use information from the python field
+ if view_type not in ("kanban", "tree", "form", "setting"):
+ expr_to_attr(item)
+ continue
+
+ f_name = item.get("name")
+
+ if f_name not in model._fields:
+ _logger.warning(
+ "Unknown field %r from %r, can not migrate 'states' python field attribute in view %s",
+ f_name,
+ model._name,
+ ref,
+ )
+ continue
+
+ field = model._fields[f_name]
+
+ # get subviews
+ if field.comodel_name:
+ for subview in item.getchildren():
+ subview_type = subview.tag if subview.tag != "groupby" else "form"
+ updated, fnodes = convert_node_modifiers_inplace(
+ subview, env, env[field.comodel_name], subview_type, ref
+ )
+ analysed_nodes.update(fnodes)
+ updated_nodes.update(updated)
+
+ # use python field to convert view
+ if item.get("readonly"):
+ expr_to_attr(item, field=field)
+ elif field.states:
+ readonly = bool(field.readonly)
+ fnames = [k for k, v in field.states.items() if v[0][1] != readonly]
+ if fnames:
+ fnames.sort()
+ dom = [("state", "not in" if readonly else "in", fnames)]
+ expr_to_attr(
+ item,
+ py_field_modifiers={"readonly": domain_to_expression(dom)},
+ field=field,
+ )
+ else:
+ expr_to_attr(item)
+ elif field.readonly not in (True, False):
+ try:
+ readonly_expr = domain_to_expression(str(field.readonly))
+ except ValueError:
+ _logger.warning("Can not convert readonly: %r", field.readonly)
+ continue
+ if readonly_expr in ("0", "1"):
+ readonly_expr = str(readonly_expr == "1")
+ expr_to_attr(
+ item, py_field_modifiers={"readonly": readonly_expr}, field=field
+ )
+ else:
+ expr_to_attr(item, field=field)
+
+ # processes all elements that have not been converted
+ for item in unique(
+ itertools.chain(
+ root.findall(".//*[@attrs]"),
+ root.findall(".//*[@states]"),
+ root.findall(".//tree/*[@invisible]"),
+ )
+ ):
+ expr_to_attr(item)
+
+ return updated_nodes, analysed_nodes
+
+
+reg_comment = r""
+reg_att1 = r'[a-zA-Z0-9._-]+\s*=\s*"(?:\n|[^"])*"'
+reg_att2 = r"[a-zA-Z0-9._-]+\s*=\s*'(?:\n|[^'])*'"
+reg_open_tag = rf"""<[a-zA-Z0-9]+(?:\s*\n|\s+{reg_att1}|\s+{reg_att2})*\s*/?>"""
+reg_close_tag = r"[a-zA-Z0-9]+\s*>"
+reg_split = (
+ rf"((?:\n|[^<])*)({reg_comment}|{reg_open_tag}|{reg_close_tag})((?:\n|[^<])*)"
+)
+reg_attrs = r""" (attrs|states|invisible|column_invisible|readonly|required)=("(?:\n|[^"])*"|'(?:\n|[^'])*')"""
+close_placeholder = ""
+
+
+def split_xml(arch):
+ """split xml in tags, add a close tag for each void."""
+ split = list(re.findall(reg_split, arch.replace("/>", f"/>{close_placeholder}")))
+ return split
+
+
+def get_targeted_xml_content(spec, field_arch_content):
+ spec_xml = etree.tostring(spec, encoding="unicode").strip()
+ if spec_xml in field_arch_content:
+ return spec_xml
+
+ for ancestor in spec.iterancestors():
+ if ancestor.tag in ("field", "data"):
+ break
+
+ spec_index = ancestor.index(spec)
+
+ xml = ""
+ level = 0
+ index = 0
+ for before, tag, after in split_xml(field_arch_content):
+ if index - 1 == spec_index:
+ xml += before + tag + after
+ if tag[1] == "/":
+ level -= 1
+ elif tag[1] != "!":
+ level += 1
+ if level == 1:
+ index += 1
+
+ if not xml:
+ ValueError("Source inheritance spec not found for %s: %s", ref, spec_xml)
+
+ return xml.replace(close_placeholder, "").strip()
+
+
+def replace_and_keep_indent(element, arch, ref):
+ """Generate micro-diff from updated attributes"""
+ next_record = (
+ etree.tostring(element, encoding="unicode").replace(""", "'").strip()
+ )
+ n_split = split_xml(next_record)
+ arch = arch.strip()
+ p_split = split_xml(arch)
+
+ control = ""
+ level = 0
+ for i in range(max(len(p_split), len(n_split))):
+ p_node = p_split[i][1]
+ n_node = n_split[i][1]
+ control += "".join(p_split[i])
+
+ if p_node[1] != "/" and p_node[1] != "!":
+ level += 1
+
+ replace_by = p_node
+ if p_node != n_node:
+ if p_node == close_placeholder and not n_node.startswith(""):
+ raise ValueError(
+ "Wrong split for convertion in %s\n\n---------\nSource node: None\nCurrent node: %s\nSource arch: %s\nCurrent arch: %s"
+ % (ref, n_node, arch, next_record)
+ )
+ if n_node == close_placeholder and not p_node.startswith(""):
+ raise ValueError(
+ "Wrong split for convertion in %s\n\n---------\nSource node: %s\nCurrent node: None\nSource arch: %s\nCurrent arch: %s"
+ % (ref, p_node, arch, next_record)
+ )
+
+ p_tag = re.split(r"[<>\n /]+", p_node, 2)[1]
+ n_tag = re.split(r"[<>\n /]+", n_node, 2)[1]
+ if (
+ p_node != close_placeholder
+ and n_node != close_placeholder
+ and p_tag != n_tag
+ ):
+ raise ValueError(
+ "Wrong split for convertion in %s\n\n---------\nSource node: %s\nCurrent node: %s\nSource arch: %s\nCurrent arch: %s"
+ % (ref, p_node, n_node, arch, next_record)
+ )
+
+ p_attrs = {k: v[1:-1] for k, v in re.findall(reg_attrs, p_node)}
+ n_attrs = {k: v[1:-1] for k, v in re.findall(reg_attrs, n_node)}
+
+ if p_attrs != n_attrs:
+ if p_attrs:
+ key, value = p_attrs.popitem()
+ for j in p_attrs:
+ replace_by = replace_by.replace(f' {j}="{p_attrs[j]}"', "")
+ rep = ""
+ if n_attrs:
+ space = re.search(rf"(\n? +){key}=", replace_by).group(1)
+ rep = " " + space.join(f'{k}="{v}"' for k, v in n_attrs.items())
+ replace_by = re.sub(
+ r""" %s=["']%s["']""" % (re.escape(key), re.escape(value)),
+ rep,
+ replace_by,
+ )
+ replace_by = re.sub("(?: *\n +)+(\n +)", r"\1", replace_by)
+ replace_by = re.sub("(?: *\n +)(/?>)", r"\1", replace_by)
+ else:
+ rep = ""
+ if n_attrs:
+ rep = " " + " ".join(f'{k}="{v}"' for k, v in n_attrs.items())
+ if p_node.endswith("/>"):
+ replace_by = replace_by[0:-2] + rep + "/>"
+ else:
+ replace_by = replace_by[0:-1] + rep + ">"
+
+ if p_node[1] == "/":
+ level -= 1
+
+ p_split[i] = (p_split[i][0], replace_by, p_split[i][2])
+
+ xml = "".join("".join(s) for s in p_split).replace(f"/>{close_placeholder}", "/>")
+
+ control = control.replace(f"/>{close_placeholder}", "/>")
+
+ if not control or level != 0:
+ _logger.error("Wrong convertion in %s\n\n%s", ref, control)
+ raise ValueError("Missing update: \n{control}")
+
+ return xml
+
+
+def extract_node_modifiers(node, view_type, py_field_modifiers=None):
+ """extract the node modifiers and concat attributes (attrs, states...)"""
+
+ modifiers = {}
+
+ # modifiers from deprecated attrs
+ #
+ # =>
+ # modfiers['invisible'] = 'user_id == uid'
+ # modfiers['readonly'] = 'name == "toto"'
+ attrs = ast.literal_eval(node.attrib.get("attrs", "{}")) or {}
+ for modifier, val in attrs.items():
+ try:
+ domain = modifier_to_domain(val)
+ py_expression = domain_to_expression(domain)
+ except Exception as error:
+ raise ValueError(
+ f"Invalid modifier {modifier!r}: {val!r}\n{error}"
+ ) from error
+ modifiers[modifier] = py_expression
+
+ # invisible modifier from deprecated states
+ #
+ # =>
+ # modifiers['invisible'] = "state not in ('draft', 'done')"
+ states = node.attrib.get("states")
+ if states:
+ value = tuple(states.split(","))
+ if len(value) == 1:
+ py_expression = f"state != {value[0]!r}"
+ else:
+ py_expression = f"state not in {value!r}"
+ invisible = modifiers.get("invisible") or "False"
+ if invisible == "False":
+ modifiers["invisible"] = py_expression
+ else:
+ # only add parenthesis if necessary
+ if " and " in py_expression or " or " in py_expression:
+ py_expression = f"({py_expression})"
+ if " and " in invisible or " or " in invisible:
+ invisible = f"({invisible})"
+ modifiers["invisible"] = f"{invisible} and {py_expression}"
+
+ # extract remaining modifiers
+ #
+ for modifier in ("column_invisible", "invisible", "readonly", "required"):
+ py_expression = node.attrib.get(modifier, "").strip()
+ if not py_expression:
+ if (
+ modifier not in modifiers
+ and py_field_modifiers
+ and py_field_modifiers.get(modifier)
+ ):
+ modifiers[modifier] = py_field_modifiers[modifier]
+ continue
+
+ try:
+ # most (~95%) elements are 1/True/0/False
+ py_expression = repr(str2bool(py_expression))
+ except ValueError:
+ # otherwise, make sure it is a valid expression
+ try:
+ modifier_ast = ast.parse(f"({py_expression})", mode="eval").body
+ py_expression = repr(_modifier_to_domain_ast_leaf(modifier_ast))
+ except Exception as error:
+ raise ValueError(
+ f"Invalid modifier {modifier!r}: {error}: {py_expression!r}"
+ ) from None
+
+ # Special case, must rename "invisible" to "column_invisible"
+ if (
+ modifier == "invisible"
+ and py_expression != "False"
+ and not get_expression_field_names(py_expression)
+ ):
+ parent_view_type = view_type
+ for parent in node.iterancestors():
+ if parent.tag in (
+ "tree",
+ "form",
+ "setting",
+ "kanban",
+ "calendar",
+ "search",
+ ):
+ parent_view_type = parent.tag
+ break
+ if parent.tag in (
+ "groupby",
+ "header",
+ ): # tree view element with form view behavior
+ parent_view_type = "form"
+ break
+ if parent_view_type == "tree":
+ modifier = "column_invisible"
+
+ # previous_py_expr and py_expression must be OR-ed
+ # first 3 cases are short circuits
+ previous_py_expr = modifiers.get(modifier, "False")
+ if (
+ previous_py_expr == "True" or py_expression == "True" # True or ... => True
+ ): # ... or True => True
+ modifiers[modifier] = "True"
+ elif previous_py_expr == "False": # False or ... => ...
+ modifiers[modifier] = py_expression
+ elif py_expression == "False": # ... or False => ...
+ modifiers[modifier] = previous_py_expr
+ else:
+ # only add parenthesis if necessary
+ if " and " in previous_py_expr or " or " in previous_py_expr:
+ previous_py_expr = f"({previous_py_expr})"
+ modifiers[modifier] = f"{py_expression} or {previous_py_expr}"
+
+ return modifiers
+
+
+def domain_to_expression(domain):
+ """Convert the given domain into a python expression"""
+ domain = normalize_domain(domain)
+ domain = distribute_not(domain)
+ operators = []
+ expression = []
+ for leaf in reversed(domain):
+ if leaf == AND_OPERATOR:
+ right = expression.pop()
+ if operators.pop() == OR_OPERATOR:
+ right = f"({right})"
+ left = expression.pop()
+ if operators.pop() == OR_OPERATOR:
+ left = f"({left})"
+ expression.append(f"{right} and {left}")
+ operators.append(leaf)
+ elif leaf == OR_OPERATOR:
+ right = expression.pop()
+ operators.pop()
+ left = expression.pop()
+ operators.pop()
+ expression.append(f"{right} or {left}")
+ operators.append(leaf)
+ elif leaf == NOT_OPERATOR:
+ expr = expression.pop()
+ operators.pop()
+ expression.append(f"not ({expr})")
+ operators.append(leaf)
+ elif leaf is True or leaf is False:
+ expression.append(repr(leaf))
+ operators.append(None)
+ elif isinstance(leaf, (tuple, list)):
+ left, op, right = leaf
+ if left == 1: # from TRUE_LEAF
+ expr = "True"
+ elif left == 0: # from FALSE_LEAF
+ expr = "False"
+ elif isinstance(left, ContextDependentDomainItem):
+ # from expression to use TRUE_LEAF or FALSE_LEAF
+ expr = repr(left)
+ elif op == "=" or op == "==":
+ if right is False or right == []:
+ expr = f"not {left}"
+ elif left.endswith("_ids"):
+ expr = f"{right!r} in {left}"
+ elif right is True:
+ expr = f"{left}"
+ elif right is False:
+ expr = f"not {left}"
+ else:
+ expr = f"{left} == {right!r}"
+ elif op == "!=" or op == "<>":
+ if right is False or right == []:
+ expr = str(left)
+ elif left.endswith("_ids"):
+ expr = f"{right!r} not in {left}"
+ elif right is True:
+ expr = f"not {left}"
+ elif right is False:
+ expr = f"{left}"
+ else:
+ expr = f"{left} != {right!r}"
+ elif op in ("<=", "<", ">", ">="):
+ expr = f"{left} {op} {right!r}"
+ elif op == "=?":
+ expr = f"(not {right} or {left} in {right!r})"
+ elif op == "in" or op == "not in":
+ right_str = str(right)
+ if right_str == "[None, False]":
+ expr = f"not ({left})"
+ elif left.endswith("_ids"):
+ if right_str.startswith("[") and "," not in right_str:
+ expr = f"{right[0]!r} {op} {left}"
+ if not right_str.startswith("[") and right_str.endswith("id"):
+ # fix wrong use of 'in' inside domain
+ expr = f"{right_str!r} {op} {left}"
+ else:
+ raise ValueError(
+ f"Can not convert {domain!r} to python expression"
+ )
+ else:
+ if right_str.startswith("[") and "," not in right_str:
+ op = "==" if op == "in" else "!="
+ expr = f"{left} {op} {right[0]!r}"
+ else:
+ expr = f"{left} {op} {right!r}"
+ elif op == "like" or op == "not like":
+ if isinstance(right, str):
+ part = right.split("%")
+ if len(part) == 1:
+ op = "in" if op == "like" else "not in"
+ expr = f'{right!r} {op} ({left} or "")'
+ elif len(part) == 2:
+ if part[0] and part[1]:
+ expr = f'({left} or "").startswith({part[0]!r}) and ({left} or "").endswith({part[1]!r})'
+ elif part[0]:
+ expr = f'({left} or "").startswith({part[0]!r})'
+ elif part[1]:
+ expr = f'({left} or "").endswith({part[0]!r})'
+ else:
+ expr = str(left)
+ if op.startswith("not "):
+ expr = f"not ({expr})"
+ else:
+ raise ValueError(
+ f"Can not convert {domain!r} to python expression"
+ )
+ else:
+ op = "in" if op == "like" else "not in"
+ expr = f'{right!r} {op} ({left} or "")'
+ elif op == "ilike" or op == "not ilike":
+ if isinstance(right, str):
+ part = right.split("%")
+ if len(part) == 1:
+ op = "in" if op == "ilike" else "not in"
+ expr = f'{right!r}.lower() {op} ({left} or "").lower()'
+ elif len(part) == 2:
+ if part[0] and part[1]:
+ expr = f'({left} or "").lower().startswith({part[0]!r}) and ({left} or "").lower().endswith({part[1]!r})'
+ elif part[0]:
+ expr = f'({left} or "").lower().startswith({part[0]!r})'
+ elif part[1]:
+ expr = f'({left} or "").lower().endswith({part[0]!r})'
+ else:
+ expr = str(left)
+ if op.startswith("not "):
+ expr = f"not ({expr})"
+ else:
+ raise ValueError(
+ f"Can not convert {domain!r} to python expression"
+ )
+ else:
+ op = "in" if op == "like" else "not in"
+ expr = f'{right!r}.lower() {op} ({left} or "").lower()'
+ else:
+ raise ValueError(f"Can not convert {domain!r} to python expression")
+ expression.append(expr)
+ operators.append(None)
+ else:
+ expression.append(repr(leaf))
+ operators.append(None)
+
+ return expression.pop()
+
+
+class ContextDependentDomainItem:
+ def __init__(self, value, names, returns_boolean=False, returns_domain=False):
+ self.value = value
+ self.contextual_values = names
+ self.returns_boolean = returns_boolean
+ self.returns_domain = returns_domain
+
+ def __str__(self):
+ if self.returns_domain:
+ return repr(self.value)
+ return self.value
+
+ def __repr__(self):
+ return self.__str__()
+
+
+def _modifier_to_domain_ast_wrap_domain(modifier_ast):
+ try:
+ domain_item = _modifier_to_domain_ast_leaf(
+ modifier_ast, should_contain_domain=True
+ )
+ except Exception as e:
+ raise ValueError(
+ f"{e}\nExpression must returning a valid domain in all cases"
+ ) from None
+
+ if (
+ not isinstance(domain_item, ContextDependentDomainItem)
+ or not domain_item.returns_domain
+ ):
+ raise ValueError("Expression must returning a valid domain in all cases")
+ return domain_item.value
+
+
+def _modifier_to_domain_ast_domain(modifier_ast):
+ # ['|', ('a', '=', 'b'), ('user_id', '=', uid)]
+
+ if not isinstance(modifier_ast, ast.List):
+ raise ValueError("This part must be a domain") from None
+
+ domain = []
+ for leaf in modifier_ast.elts:
+ if isinstance(leaf, ast.Str) and leaf.s in DOMAIN_OPERATORS:
+ # !, |, &
+ domain.append(leaf.s)
+ elif isinstance(leaf, ast.Constant):
+ if leaf.value is True or leaf.value is False:
+ domain.append(leaf.value)
+ else:
+ raise InvalidDomainError()
+ elif isinstance(leaf, (ast.List, ast.Tuple)):
+ # domain tuple
+ if len(leaf.elts) != 3:
+ raise InvalidDomainError()
+ elif not isinstance(leaf.elts[0], ast.Constant) and not (
+ isinstance(leaf.elts[2], ast.Constant) and leaf.elts[2].value == 1
+ ):
+ raise InvalidDomainError()
+ elif not isinstance(leaf.elts[1], ast.Constant):
+ raise InvalidDomainError()
+
+ left_ast, operator_ast, right_ast = leaf.elts
+
+ operator = operator_ast.value
+ if operator == "==":
+ operator = "="
+ elif operator == "<>":
+ operator = "!="
+ elif operator not in TERM_OPERATORS:
+ raise InvalidDomainError()
+
+ left = _modifier_to_domain_ast_leaf(left_ast)
+ right = _modifier_to_domain_ast_leaf(right_ast)
+ domain.append((left, operator, right))
+ else:
+ item = _modifier_to_domain_ast_leaf(leaf)
+ domain.append(item)
+ if (
+ item not in (True, False)
+ and isinstance(item, ContextDependentDomainItem)
+ and not item.returns_boolean
+ ):
+ raise InvalidDomainError()
+
+ return normalize_domain(domain)
+
+
+def _modifier_to_domain_ast_leaf(
+ item_ast, should_contain_domain=False, need_parenthesis=False
+):
+ # [('a', '=', True)]
+ # True
+ if isinstance(item_ast, ast.Constant):
+ return item_ast.value
+
+ # [('a', '=', 'b')]
+ # 'b'
+ if isinstance(item_ast, ast.Str):
+ return item_ast.s
+
+ # [('a', '=', 1)] if context.get('b') else []
+ # [('a', '=', 1)]
+ if should_contain_domain and isinstance(item_ast, ast.List):
+ domain = _modifier_to_domain_ast_domain(item_ast)
+ _fnames, vnames = get_domain_value_names(domain)
+ return ContextDependentDomainItem(domain, vnames, returns_domain=True)
+
+ # [('obj_ids', 'in', [uid or False, 33])]
+ # [uid or False, 33]
+ if isinstance(item_ast, (ast.List, ast.Tuple)):
+ vnames = set()
+ values = []
+ for item in item_ast.elts:
+ value = _modifier_to_domain_ast_leaf(item)
+ if isinstance(value, ContextDependentDomainItem):
+ vnames.update(value.contextual_values)
+ values.append(value)
+
+ if isinstance(item_ast, ast.Tuple):
+ values = tuple(values)
+
+ if vnames:
+ return ContextDependentDomainItem(repr(values), vnames)
+ else:
+ return values
+
+ # [('a', '=', uid)]
+ # uid
+ if isinstance(item_ast, ast.Name):
+ vnames = {item_ast.id}
+ return ContextDependentDomainItem(item_ast.id, vnames)
+
+ # [('a', '=', parent.b)]
+ # parent.b
+ if isinstance(item_ast, ast.Attribute):
+ vnames = set()
+ name = _modifier_to_domain_ast_leaf(item_ast.value, need_parenthesis=True)
+ if isinstance(name, ContextDependentDomainItem):
+ vnames.update(name.contextual_values)
+ value = f"{name!r}.{item_ast.attr}"
+ if value.startswith("parent."):
+ vnames.add(value)
+ return ContextDependentDomainItem(value, vnames)
+
+ # [('a', '=', company_ids[1])]
+ # [1]
+ if isinstance(item_ast, ast.Index): # deprecated python ast class for Subscript key
+ return _modifier_to_domain_ast_leaf(item_ast.value)
+
+ # [('a', '=', company_ids[1])]
+ # [1]
+ if isinstance(item_ast, ast.Subscript):
+ vnames = set()
+ name = _modifier_to_domain_ast_leaf(item_ast.value, need_parenthesis=True)
+ if isinstance(name, ContextDependentDomainItem):
+ vnames.update(name.contextual_values)
+
+ key = _modifier_to_domain_ast_leaf(item_ast.slice)
+ if isinstance(key, ContextDependentDomainItem):
+ vnames.update(key.contextual_values)
+ value = f"{name!r}[{key!r}]"
+
+ return ContextDependentDomainItem(value, vnames)
+
+ # [('a', '=', context.get('abc', 'default') == 'b')]
+ # ==
+ if isinstance(item_ast, ast.Compare):
+ vnames = set()
+
+ if len(item_ast.ops) > 1:
+ raise ValueError(f"Should not more than one comparaison: {expr}")
+
+ left = _modifier_to_domain_ast_leaf(item_ast.left, need_parenthesis=True)
+ if isinstance(left, ContextDependentDomainItem):
+ vnames.update(left.contextual_values)
+
+ operator = AST_OP_TO_STR[type(item_ast.ops[0])]
+
+ right = _modifier_to_domain_ast_leaf(
+ item_ast.comparators[0], need_parenthesis=True
+ )
+ if isinstance(right, ContextDependentDomainItem):
+ vnames.update(right.contextual_values)
+
+ expr = f"{left!r} {operator} {right!r}"
+ return ContextDependentDomainItem(expr, vnames, returns_boolean=True)
+
+ # [('a', '=', 1 - 3]
+ # 1 - 3
+ if isinstance(item_ast, ast.BinOp):
+ vnames = set()
+
+ left = _modifier_to_domain_ast_leaf(item_ast.left)
+ if isinstance(left, ContextDependentDomainItem):
+ vnames.update(left.contextual_values)
+
+ operator = AST_OP_TO_STR[type(item_ast)]
+
+ right = _modifier_to_domain_ast_leaf(item_ast.right)
+ if isinstance(right, ContextDependentDomainItem):
+ vnames.update(right.contextual_values)
+
+ expr = f"{left!r} {operator} {right!r}"
+ return ContextDependentDomainItem(expr, vnames)
+
+ # [(1, '=', field_name and 1 or 0]
+ # field_name and 1
+ if isinstance(item_ast, ast.BoolOp):
+ vnames = set()
+
+ returns_boolean = True
+ returns_domain = False
+
+ values = []
+ for ast_value in item_ast.values:
+ value = _modifier_to_domain_ast_leaf(
+ ast_value, should_contain_domain, need_parenthesis=True
+ )
+ if isinstance(value, ContextDependentDomainItem):
+ vnames.update(value.contextual_values)
+ if not value.returns_boolean:
+ returns_boolean = False
+ if value.returns_domain:
+ returns_domain = True
+ elif not isinstance(value, bool):
+ returns_boolean = False
+ values.append(repr(value))
+
+ if returns_domain:
+ raise ValueError(
+ "Use if/else condition instead of boolean operator to return domain."
+ )
+
+ if isinstance(item_ast.op, ast.Or):
+ expr = " or ".join(values)
+ else:
+ expr = " and ".join(values)
+ if need_parenthesis and " " in expr:
+ expr = f"({expr})"
+ return ContextDependentDomainItem(expr, vnames, returns_boolean=returns_boolean)
+
+ # [('a', '=', not context.get('abc', 'default')), ('a', '=', -1)]
+ # not context.get('abc', 'default')
+ if isinstance(item_ast, ast.UnaryOp):
+ if (
+ isinstance(item_ast.operand, ast.Constant)
+ and isinstance(item_ast.op, ast.USub)
+ and isinstance(item_ast.operand.value, (int, float))
+ ):
+ return -item_ast.operand.value
+
+ leaf = _modifier_to_domain_ast_leaf(item_ast.operand, need_parenthesis=True)
+ vnames = set()
+ if isinstance(leaf, ContextDependentDomainItem):
+ vnames.update(leaf.contextual_values)
+
+ expr = f"not {leaf!r}"
+ return ContextDependentDomainItem(expr, vnames, returns_boolean=True)
+
+ # [('a', '=', int(context.get('abc', False))]
+ # context.get('abc', False)
+ if isinstance(item_ast, ast.Call):
+ vnames = set()
+
+ name = _modifier_to_domain_ast_leaf(item_ast.func, need_parenthesis=True)
+ if isinstance(name, ContextDependentDomainItem) and name.value not in _BUILTINS:
+ vnames.update(name.contextual_values)
+ returns_boolean = str(name) == "bool"
+
+ values = []
+ for arg in item_ast.args:
+ value = _modifier_to_domain_ast_leaf(arg)
+ if isinstance(value, ContextDependentDomainItem):
+ vnames.update(value.contextual_values)
+ values.append(repr(value))
+
+ expr = f"{name!r}({', '.join(values)})"
+ return ContextDependentDomainItem(expr, vnames, returns_boolean=returns_boolean)
+
+ # [('a', '=', 1 if context.get('abc', 'default') == 'b' else 0)]
+ # 1 if context.get('abc', 'default') == 'b' else 0
+ if isinstance(item_ast, ast.IfExp):
+ vnames = set()
+
+ test = _modifier_to_domain_ast_leaf(item_ast.test)
+ if isinstance(test, ContextDependentDomainItem):
+ vnames.update(test.contextual_values)
+
+ returns_boolean = True
+ returns_domain = True
+
+ body = _modifier_to_domain_ast_leaf(
+ item_ast.body, should_contain_domain, need_parenthesis=True
+ )
+ if isinstance(body, ContextDependentDomainItem):
+ vnames.update(body.contextual_values)
+ if not body.returns_boolean:
+ returns_boolean = False
+ if not body.returns_domain:
+ returns_domain = False
+ else:
+ returns_domain = False
+ if not isinstance(body, bool):
+ returns_boolean = False
+
+ orelse = _modifier_to_domain_ast_leaf(
+ item_ast.orelse, should_contain_domain, need_parenthesis=True
+ )
+ if isinstance(orelse, ContextDependentDomainItem):
+ vnames.update(orelse.contextual_values)
+ if not orelse.returns_boolean:
+ returns_boolean = False
+ if not orelse.returns_domain:
+ returns_domain = False
+ else:
+ returns_domain = False
+ if not isinstance(orelse, bool):
+ returns_boolean = False
+
+ if returns_domain:
+ # [('id', '=', 42)] if parent.a else []
+ not_test = ContextDependentDomainItem(
+ f"not ({test})", vnames, returns_boolean=True
+ )
+ if (
+ not isinstance(test, ContextDependentDomainItem)
+ or not test.returns_boolean
+ ):
+ test = ContextDependentDomainItem(
+ f"bool({test})", vnames, returns_boolean=True
+ )
+ # ['|', '&', bool(parent.a), ('id', '=', 42), not parent.a]
+ expr = ["|", "&", test] + body.value + ["&", not_test] + orelse.value
+ else:
+ expr = f"{body!r} if {test} else {orelse!r}"
+
+ return ContextDependentDomainItem(
+ expr, vnames, returns_boolean=returns_boolean, returns_domain=returns_domain
+ )
+
+ if isinstance(item_ast, ast.Expr):
+ return _modifier_to_domain_ast_leaf(item_ast.value)
+
+ raise ValueError(f"Undefined item {item_ast!r}.")
+
+
+def _modifier_to_domain_validation(domain):
+ for leaf in domain:
+ if leaf is True or leaf is False or leaf in DOMAIN_OPERATORS:
+ continue
+ try:
+ left, operator, _right = leaf
+ except ValueError:
+ raise InvalidDomainError()
+ except TypeError:
+ if isinstance(leaf, ContextDependentDomainItem):
+ if leaf.returns_boolean:
+ continue
+ raise InvalidDomainError()
+ raise InvalidDomainError()
+ if leaf not in (TRUE_LEAF, FALSE_LEAF) and not isinstance(left, str):
+ raise InvalidDomainError()
+ if operator not in VALID_TERM_OPERATORS:
+ raise InvalidDomainError()
+
+
+def modifier_to_domain(modifier):
+ """
+ Convert modifier values to domain. Generated domains can contain
+ contextual elements (right part of domain leaves). The domain can be
+ concatenated with others using the `AND` and `OR` methods.
+ The representation of the domain can be evaluated with the corresponding
+ context.
+
+ :params modifier (bool|0|1|domain|str|ast)
+ :return a normalized domain (list(tuple|"&"|"|"|"!"|True|False))
+ """
+
+ if isinstance(modifier, bool):
+ return [TRUE_LEAF if modifier else FALSE_LEAF]
+ if isinstance(modifier, int):
+ return [TRUE_LEAF if modifier else FALSE_LEAF]
+ if isinstance(modifier, (list, tuple)):
+ _modifier_to_domain_validation(modifier)
+ return normalize_domain(modifier)
+ if isinstance(modifier, ast.AST):
+ try:
+ return _modifier_to_domain_ast_domain(modifier)
+ except Exception as e:
+ raise ValueError(f"{e}: {modifier!r}") from None
+
+ # modifier is a string
+ modifier = modifier.strip()
+
+ # most (~95%) elements are 1/True/0/False
+ if modifier.lower() in ("0", "false"):
+ return [FALSE_LEAF]
+ if modifier.lower() in ("1", "true"):
+ return [TRUE_LEAF]
+
+ # [('a', '=', 'b')]
+ try:
+ domain = ast.literal_eval(modifier)
+ _modifier_to_domain_validation(domain)
+ return normalize_domain(domain)
+ except SyntaxError:
+ raise ValueError(f"Wrong domain python syntax: {modifier}")
+ except ValueError:
+ pass
+
+ # [('a', '=', parent.b), ('a', '=', context.get('b'))]
+ try:
+ modifier_ast = ast.parse(f"({modifier})", mode="eval").body
+ if isinstance(modifier_ast, ast.List):
+ return _modifier_to_domain_ast_domain(modifier_ast)
+ else:
+ return _modifier_to_domain_ast_wrap_domain(modifier_ast)
+ except Exception as e:
+ raise ValueError(f"{e}: {modifier}")
+
+
+def str2bool(s):
+ s = s.lower()
+ if s in ("1", "true"):
+ return True
+ if s in ("0", "false"):
+ return False
+ raise ValueError()
diff --git a/views_migration_17/readme/CONTRIBUTORS.rst b/views_migration_17/readme/CONTRIBUTORS.rst
new file mode 100644
index 00000000000..e7f9b006105
--- /dev/null
+++ b/views_migration_17/readme/CONTRIBUTORS.rst
@@ -0,0 +1,4 @@
+* `ADHOC SA `_:
+
+ * Juan José Scarafía
+ * Bruno Zanotti
diff --git a/views_migration_17/readme/DESCRIPTION.rst b/views_migration_17/readme/DESCRIPTION.rst
new file mode 100644
index 00000000000..6eb774fdf5d
--- /dev/null
+++ b/views_migration_17/readme/DESCRIPTION.rst
@@ -0,0 +1,13 @@
+``views-migration-v17`` is a odoo server mode module that allows you to automatically migrate the views of a Odoo module versión <= v16 to v17 .
+
+For example::
+
+
+
+
+
+To::
+
+
+
+
diff --git a/views_migration_17/readme/USAGE.rst b/views_migration_17/readme/USAGE.rst
new file mode 100644
index 00000000000..47e525639b6
--- /dev/null
+++ b/views_migration_17/readme/USAGE.rst
@@ -0,0 +1,11 @@
+This module is not installable, to use this module, you need to:
+
+1. Run odoo with this module as a server module:
+
+.. code-block:: shell
+
+ odoo -d DATABASE_NAME -i MODULE_TO_MIGRATE --load=base,web,views_migration_17 --stop-after-init
+
+
+2. If success the modifications will be in the source code of your module.
+