# 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(""): 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"" 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("\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()