# 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()