# -*- coding: utf-8 -*- import re from bs4 import BeautifulSoup as bs from ast import literal_eval import logging # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Constants NEW_ATTRS = ['invisible', 'required', 'readonly', 'column_invisible'] def upgrade(file_manager): """Upgrade XML files by converting 'attrs' and 'states' into new attributes.""" # Filter files to only XML files files = [file for file in file_manager if file.path.suffix == '.xml'] if not files: return # Helper functions def normalize_domain(domain): """ Normalize Domain, taken from odoo/osv/expression.py -> just the part so that & operators are added where needed. """ if len(domain) == 1: return domain result = [] expected = 1 # expected number of expressions op_arity = {'!': 1, '&': 2, '|': 2} for token in domain: if expected == 0: # more than expected, like in [A, B] result[0:0] = ['&'] # put an extra '&' in front expected = 1 if isinstance(token, (list, tuple)): # domain term expected -= 1 token = tuple(token) else: expected += op_arity.get(token, 0) - 1 result.append(token) return result def stringify_leaf(leaf): """ Convert a domain leaf to python expression string """ operator = str(leaf[1]) left_operand = leaf[0] right_operand = leaf[2] # Handle '=?' if operator == '=?': if isinstance(right_operand, str): right_operand = f"'{right_operand}'" return f"({right_operand} in [None, False] or {left_operand} == {right_operand})" # Handle '=' elif operator == '=': if right_operand in (False, []): # Check for False or empty list return f"not {left_operand}" elif right_operand == True: # Check for True return left_operand operator = '==' # Handle '!=' elif operator == '!=': if right_operand in (False, []): # Check for False or empty list return left_operand elif right_operand == True: # Check for True return f"not {left_operand}" # Handle 'like' operators elif 'like' in operator: case_insensitive = 'ilike' in operator if isinstance(right_operand, str) and re.search('[_%]', right_operand): # Since wildcards won't work/be recognized after conversion we throw an error raise Exception("Script doesn't support 'like' domains with wildcards") if operator in ['=like', '=ilike']: operator = '==' else: if 'not' in operator: operator = 'not in' else: operator = 'in' # Switch operands for 'in' operations left_operand, right_operand = right_operand, left_operand # Format right operand if isinstance(right_operand, str): right_operand = f"'{right_operand}'" # Handle case insensitive operations if 'like' in str(leaf[1]) and 'ilike' in str(leaf[1]): return f"{left_operand}.lower() {operator} {right_operand}.lower()" else: return f"{left_operand} {operator} {right_operand}" def stringify_attr(stack): """ Convert domain stack to python expression string """ if stack in (True, False, 'True', 'False', 1, 0, '1', '0'): return str(stack) try: last_parenthesis_index = max(index for index, item in enumerate(stack[::-1]) if item not in ('|', '!')) except ValueError: last_parenthesis_index = 0 stack = normalize_domain(stack) stack = stack[::-1] result = [] for index, leaf_or_operator in enumerate(stack): if leaf_or_operator == '!': expr = result.pop() result.append('(not (%s))' % expr) elif leaf_or_operator in ['&', '|']: left = result.pop() try: right = result.pop() except IndexError: res = left + ('%s' % ' and' if leaf_or_operator == '&' else ' or') result.append(res) continue form = '(%s %s %s)' if index > last_parenthesis_index: form = '%s %s %s' result.append(form % (left, 'and' if leaf_or_operator == '&' else 'or', right)) else: result.append(stringify_leaf(leaf_or_operator)) return result[0] if result else '' def get_new_attrs(attrs): """ Parse attrs string and return dictionary of new attributes """ new_attrs = {} try: # Temporarily replace dynamic variables in leafs escaped_operators = ['=', '!=', '>', '>=', '<', '<=', '=\\?', '=like', 'like', 'not like', 'ilike', 'not ilike', '=ilike', 'in', 'not in', 'child_of', 'parent_of'] # Handle HTML entities attrs = re.sub("<", "<", attrs) attrs = re.sub(">", ">", attrs) # Replace dynamic variables with placeholder strings attrs = re.sub(f"([\"'](?:{'|'.join(escaped_operators)})[\"']\\s*,\\s*)(?!False|True)([\\w\\.]+)(?=\\s*[\\]\\)])", r"\1'__dynamic_variable__.\2'", attrs) attrs = re.sub(r"(%\([\w\.]+\)d)", r"'__dynamic_variable__.\1'", attrs) attrs = attrs.strip() if re.search("^{.*}$", attrs, re.DOTALL): attrs_dict = literal_eval(attrs.strip()) for attr, attr_value in attrs_dict.items(): if attr not in NEW_ATTRS: continue stringified_attr = stringify_attr(attr_value) if isinstance(stringified_attr, str): # Convert dynamic variable strings back to their original form stringified_attr = re.sub(r"'__dynamic_variable__\.([^']+)'", r"\1", stringified_attr) new_attrs[attr] = stringified_attr return new_attrs except Exception as e: logger.error(f"Failed to parse attrs '{attrs}': {e}") return {} def get_combined_invisible_condition(invisible_attribute, states_attribute): """ Combine invisible attribute condition with states attribute """ invisible_attribute = invisible_attribute.strip() if invisible_attribute else '' states_attribute = states_attribute.strip() if states_attribute else '' if not states_attribute: return invisible_attribute states_list = [f"'{s.strip()}'" for s in states_attribute.split(',')] states_to_add = f"state not in [{','.join(states_list)}]" if invisible_attribute: if invisible_attribute.endswith(('or', 'and')): combined_invisible_condition = f"{invisible_attribute} {states_to_add}" else: combined_invisible_condition = f"{invisible_attribute} or {states_to_add}" else: combined_invisible_condition = states_to_add return combined_invisible_condition # Process each file for fileno, file in enumerate(files, start=1): try: content = file.content # Skip if no attrs or states if not ('attrs' in content or 'states' in content): continue # Parse XML with BeautifulSoup soup = bs(content, 'xml') tags_with_attrs = soup.select('[attrs]') attr_tags = soup.select('attribute[name="attrs"]') tags_with_states = soup.select('[states]') state_tags = soup.select('attribute[name="states"]') if not (tags_with_attrs or attr_tags or tags_with_states or state_tags): continue # Process tags with attrs for tag in tags_with_attrs: attrs_value = tag.get('attrs', '') new_attrs = get_new_attrs(attrs_value) if new_attrs: # Remove the original attrs attribute del tag['attrs'] # Add new attributes, handling existing ones for attr_name, attr_value in new_attrs.items(): if attr_name in tag.attrs: # Combine with existing attribute old_value = tag[attr_name] if old_value in ['True', '1', True, 1]: new_value = f"True or ({attr_value})" elif old_value in ['False', '0', False, 0]: new_value = f"False or ({attr_value})" else: new_value = f"({old_value}) or ({attr_value})" tag[attr_name] = new_value else: tag[attr_name] = attr_value # Process for attr_tag in attr_tags: attrs_value = attr_tag.get_text(strip=True) if attr_tag.get_text() else '' new_attrs = get_new_attrs(attrs_value) if new_attrs: parent = attr_tag.parent # Create new attribute tags for attr_name, attr_value in new_attrs.items(): # Check if attribute already exists existing_attr = parent.find('attribute', {'name': attr_name}) if existing_attr: # Combine with existing attribute old_value = existing_attr.get_text(strip=True) if existing_attr.get_text() else '' if old_value in ['True', '1']: new_value = f"True or ({attr_value})" elif old_value in ['False', '0']: new_value = f"False or ({attr_value})" else: new_value = f"({old_value}) or ({attr_value})" existing_attr.string = new_value else: # Create new attribute tag new_tag = soup.new_tag('attribute', name=attr_name) new_tag.string = str(attr_value) attr_tag.insert_before(new_tag) # Remove the original attrs attribute tag attr_tag.decompose() # Process tags with states for tag in tags_with_states: states_value = tag.get('states', '') invisible_value = tag.get('invisible', '') new_invisible = get_combined_invisible_condition(invisible_value, states_value) # Update attributes if new_invisible: tag['invisible'] = new_invisible elif 'invisible' in tag.attrs: del tag['invisible'] # Remove states attribute del tag['states'] # Process for state_tag in state_tags: states_value = state_tag.get_text(strip=True) if state_tag.get_text() else '' parent = state_tag.parent # Find existing invisible attribute invisible_attr = parent.find('attribute', {'name': 'invisible'}) invisible_value = invisible_attr.get_text(strip=True) if invisible_attr and invisible_attr.get_text() else '' new_invisible = get_combined_invisible_condition(invisible_value, states_value) if invisible_attr: # Update existing invisible attribute invisible_attr.string = new_invisible else: # Create new invisible attribute new_tag = soup.new_tag('attribute', name='invisible') new_tag.string = new_invisible state_tag.insert_before(new_tag) # Remove the states attribute tag state_tag.decompose() # Update file content file.content = str(soup) except Exception as e: logger.error(f"Error processing file {file.path}: {e}") continue file_manager.print_progress(fileno, len(files))