diff --git a/automation_script/industry_automation.py b/automation_script/industry_automation.py index 0e7d17b142..5174d0a017 100644 --- a/automation_script/industry_automation.py +++ b/automation_script/industry_automation.py @@ -12,11 +12,13 @@ import shutil import argparse import sys +import psycopg2 from pathlib import Path import re from ast import literal_eval from lxml import etree +from lxml import html # Setup logger # Create a Logger instance directly @@ -221,6 +223,7 @@ def export_studio_customizations(self, port, db_name, studio_zip_path): _logger.error("Invalid module data: %s", module) raise Exception("Invalid module data for web_studio") + if state == "uninstalled": self.install_web_studio(port, db_name, uid, model_id) # optional: re-check status after install @@ -307,6 +310,76 @@ def delete_temp_dir(self, dir_path): shutil.rmtree(dir_path) def check_web_studio_installed(self, port, db_name, uid): + # Search AI module + search_payload = { + "jsonrpc": "2.0", + "method": "call", + "params": { + "service": "object", + "method": "execute_kw", + "args": [ + db_name, + uid, + PASSWORD, + "ir.module.module", + "search_read", + [[("name", "=", "ai")]], + { + "fields": ["id", "state"], + "limit": 1 + } + ] + }, + "id": 1 + } + + response = requests.post( + f"{BASE_URL}{port}/jsonrpc", + json=search_payload + ).json() + + if "error" in response: + raise Exception( + f"Failed to search ai module: {response['error']}" + ) + + ai_module = response.get("result", []) + + # Uninstall AI module if installed + if ai_module and ai_module[0]["state"] == "installed": + + ai_module_id = ai_module[0]["id"] + + uninstall_payload = { + "jsonrpc": "2.0", + "method": "call", + "params": { + "service": "object", + "method": "execute_kw", + "args": [ + db_name, + uid, + PASSWORD, + "ir.module.module", + "button_immediate_uninstall", + [[ai_module_id]] + ] + }, + "id": 2 + } + + uninstall_response = requests.post( + f"{BASE_URL}{port}/jsonrpc", + json=uninstall_payload + ).json() + + if "error" in uninstall_response: + raise Exception( + f"Failed to uninstall ai module: {uninstall_response['error']}" + ) + + _logger.info("AI module uninstalled successfully.") + # Prepare the JSON-RPC payload to search for the 'web_studio' module payload = { "jsonrpc": "2.0", @@ -394,12 +467,12 @@ def __init__(self, ind_name, ind_category, db_name, module_path, port, destinati 'demo/website_page.xml', 'demo/account_analytic_plan.xml', 'demo/crm_team.xml', - 'data/uom.uom.xml', + 'data/uom_uom.xml', ] self.mandatory_files = { "/data/mail_message.xml": """ - + discuss.channel email @@ -408,7 +481,7 @@ def __init__(self, ind_name, ind_category, db_name, module_path, port, destinati 🚀 Get started with Odoo {Ind_name} @@ -551,7 +624,6 @@ def clean(self): 'sale_async_emails', 'snailmail_account', 'web_grid', - 'web_studio', 'social_push_notifications', 'appointment_sms', 'website_knowledge', @@ -587,6 +659,9 @@ def clean(self): # Extract relevant SCSS customization data elif current_dir.endswith('/ir_attachment/') and ext == "scss": self.get_relevant_scss_data(scss_content_list, root, file_name) + + if not scss_content_list: + self.get_relevant_scss_data_from_db(scss_content_list) # Generate SCSS function from collected theme data self.write_scss_function(destination_module_path, scss_content_list) @@ -608,11 +683,14 @@ def clean(self): # Retain only welcome article in the knowledge article self.clean_knowledge_article(destination_module_path) + # Transform knowledge article by extracting HTML content into a QWeb template + self.transform_knowledge_article(destination_module_path) + # Add demo payment provider if relevant module is present self.add_demo_payment_provider(destination_module_path, manifest_demo_file_list) - # Add immediate install function for the theme module in demo XML files - self.add_theme_immediate_install_function(destination_module_path) + # Add theme selection function using button_choose_theme in demo XML + self.add_button_choose_theme_function(destination_module_path) # Clean up sale order line records self.clean_sale_order_line_record(destination_module_path) @@ -629,6 +707,9 @@ def clean(self): # Clean up pos.payment.method records self.clean_pos_payment_method(destination_module_path) + # Clean and edit homepage view(ir_ui_view) + self.clean_homepage_view(destination_module_path) + # Update demo file order in manifest self.arrange_manifest_file(destination_module_path, manifest_demo_file_list) @@ -972,6 +1053,70 @@ def remove_computed_fields(self, fields_info_dict, model_name, record, content): return content + def get_relevant_scss_data_from_db(self, scss_content_list): + conn = psycopg2.connect( + dbname=self.db_name, + ) + + cr = conn.cursor() + + query = """ + SELECT url, index_content + FROM ir_attachment + WHERE name IN ( + 'user_color_palette.scss', + 'user_values.scss', + 'user_theme_color_palette.scss' + ) + """ + + cr.execute(query) + + records = cr.fetchall() + + scss_pattern = re.compile(r'o-map-omit\(\(\s*(.*?)\s*(?://\s*--\s*hook\s*--)?\s*\)\)', re.DOTALL) + for file_name, raw in records: + clean_url = "/website/" + file_name.split('website/')[1] + + scss_content_dict = {} + + scss_content = raw.decode() if isinstance(raw, bytes) else raw + + scss_match = scss_pattern.search(scss_content) + + if scss_match: + lines = [] + + for line in scss_match.group(1).splitlines(): + if not line: + continue + + line = line.strip() + + if ":" in line: + key, value = line.split(":", 1) + + value = value.strip().rstrip(",") + if value == "NULL": + value = "null" + if not (value.startswith("'") and value.endswith("'")): + value = f"'{value}'" + + line = f"{key}: {value}," + + lines.append(line) + + inner_scss_content = "".join(lines) + + scss_content_dict['url'] = clean_url + scss_content_dict['inner_scss_content'] = inner_scss_content + + scss_content_list.append(scss_content_dict) + + + cr.close() + conn.close() + def get_relevant_scss_data(self, scss_content_list, root, file_name): """ Parses the given SCSS file to extract relevant customization data. @@ -1029,10 +1174,10 @@ def write_scss_function(self, destination_module_path, scss_content_list): for item in scss_content_list: new_function += f""" - - + + """ @@ -1183,6 +1328,10 @@ def remove_unused_ir_attachment_post(self, destination_module_path): record.remove(res_model[0]) if website_id: record.remove(website_id[0]) + existing = record.xpath("./field[@name='public']") + if not existing: + new_field = etree.Element("field", name="public", eval="True") + record.append(new_field) if key_field or name_field: # check key or name in ir_ui_view.xml file if not found store in list key = key_field[0].text if key_field else None @@ -1344,6 +1493,10 @@ def clean_knowledge_article(self, destination_module_path): if not record.xpath('.//field[@name="is_locked"]'): new_field = etree.Element("field", name="is_locked", eval="True") record.append(new_field) + + # Remove 'category' field if exists + if category_fields := record.xpath('.//field[@name="category"]'): + record.remove(category_fields[0]) else: # Remove all other records @@ -1352,6 +1505,46 @@ def clean_knowledge_article(self, destination_module_path): self.write_etree_content(path_knowledge_article, root_knowledge_article) return + def transform_knowledge_article(self, destination_module_path): + file_path = Path(destination_module_path + '/data/knowledge_article.xml') + + if not file_path.exists(): + return + + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() + bodyf = root.xpath("//record[@id='welcome_article']/field[@name='body']") + if bodyf is None: + return + cnt = 0 + for body in bodyf: + + if body.text: + extracted_content = body.text + body.text = "" + template = etree.Element("template", id="welcome_article_body") + wrapper = html.fragment_fromstring(extracted_content, create_parent=True) + + # Remove attribute data-heading-link-id + for el in wrapper.xpath('//*[@data-heading-link-id]'): + del el.attrib['data-heading-link-id'] + + # Append all children + template.extend(wrapper) + + root.insert(cnt, template) + cnt+=2 + else: + continue + + body.text = etree.CDATA("") + + # THIS FIXES FORMATTING + etree.indent(root, space=" ") + + tree.write(file_path, encoding="utf-8", xml_declaration=True) + def check_website_sale_installed(self): # Prepare the JSON-RPC payload to search for the 'website_sale' module @@ -1427,6 +1620,88 @@ def add_require_depends(self, depends_list): new_depends = ['knowledge'] depends_list = sorted(set(depends_list + new_depends)) return depends_list + + def clean_homepage_view(self, destination_module_path): + path_ir_ui_view = Path(destination_module_path + '/demo/ir_ui_view.xml') + + if not path_ir_ui_view.exists(): + return + + root = self.get_etree_content(path_ir_ui_view) + old_id = None + + # Find homepage and update record id + records = root.xpath("//record") + for record in records: + t_nodes = record.xpath(".//t[@t-name='website.homepage']") + if t_nodes: + old_id = record.get("id") + record.set("id", "homepage") + break + + if old_id is None: + return + + # Update function reference + functions = root.xpath("//function") + extracted_functions = [] + + for func in functions: + values = func.xpath(".//value") + + for val in values: + eval_attr = val.get("eval") + + if eval_attr and f".{old_id}" in eval_attr: + new_eval = eval_attr.replace(f".{old_id}", ".homepage") + val.set("eval", new_eval) + + extracted_functions.append(func) + + # Update website_page.xml reference + path_website_page = Path(destination_module_path + '/demo/website_page.xml') + + if path_website_page.exists(): + page_root = self.get_etree_content(path_website_page) + + records = page_root.xpath("//record") + + for record in records: + view_fields = record.xpath("./field[@name='view_id']") + + ref_attr = view_fields[0].get("ref") if view_fields else None + + # check if ref matches old_id + if ref_attr and f"{old_id}" in ref_attr: + view_fields[0].set("ref", "homepage") + + # save updated file + self.write_etree_content(path_website_page, page_root) + + # Remove function from original file + for func in extracted_functions: + parent = func.getparent() + if parent is not None: + parent.remove(func) + + # Save cleaned ir_ui_view.xml + self.write_etree_content(path_ir_ui_view, root) + + # Add function to website_theme_apply.xml + if extracted_functions: + theme_apply_path = Path(destination_module_path + '/demo/website_theme_apply.xml') + + if theme_apply_path.exists(): + theme_root = self.get_etree_content(theme_apply_path) + else: + theme_root = etree.Element("odoo") + + for func in extracted_functions: + theme_root.append(func) + + self.write_etree_content(theme_apply_path, theme_root) + + return def arrange_manifest_file(self, destination_module_path, manifest_demo_file_list): """ @@ -1517,14 +1792,17 @@ def arrange_manifest_file(self, destination_module_path, manifest_demo_file_list return - def add_theme_immediate_install_function(self, destination_module_path): + def add_button_choose_theme_function(self, destination_module_path): """ - Adds an immediate install function for the theme module in the website_theme_apply.xml file. + Adds a theme selection function (button_choose_theme) to website_theme_apply.xml. - This function reads the theme_id reference from the demo/website.xml file of the given module. - It then generates a XML element to trigger the immediate installation of the theme module. - The generated function block is appended inside the tag of demo/website_theme_apply.xml. - If the target XML file does not exist, it creates a new one with the required structure. + This function extracts the theme_id from demo/website.xml of the given module. + It then generates a XML element that triggers theme selection using + the `button_choose_theme` method on `ir.module.module`. + + The generated function block is added inside the tag of + demo/website_theme_apply.xml. If the file does not exist, it creates + a new XML file with the required structure. Args: destination_module_path (str): The module directory name containing the demo folder with website.xml. @@ -1532,11 +1810,21 @@ def add_theme_immediate_install_function(self, destination_module_path): website_path = Path(destination_module_path + '/demo/' + 'website.xml') if website_path.exists(): etree_content = self.get_etree_content(website_path) - theme_id = etree_content.xpath("//field[@name='theme_id']")[0].get('ref') + theme_id = etree_content.xpath("//field[@name='theme_id']") if theme_id: # Build new entries for each SCSS customization - new_function = f"""""" + field_node = theme_id[0] + old_ref = field_node.get('ref') + new_function = f"""""" + + field_node.attrib.pop('ref', None) + field_node.set( + 'search', + f"[('id', '=', ref('{old_ref}', raise_if_not_found=False))]" + ) + + self.write_etree_content(website_path, etree_content) # Base structure if file does not exist base_xml = f""" @@ -1643,13 +1931,30 @@ def clean_hr_employee(self, destination_module_path): # remove the records only which starts with base_industry_data. def clean_res_partner(self, destination_module_path): """ - -remove the records only which starts with base_industry_data. + -remove the records only which starts with base_industry_data and transform emails to lowercase. """ path_res_partner = Path(destination_module_path + '/demo/' + 'res_partner.xml') if path_res_partner.exists(): root_res_partner = self.get_etree_content(path_res_partner) records = root_res_partner.xpath("//record") for record in records: + # find email field inside record + email_field = record.xpath("./field[@name='email']") + + if email_field: + email = email_field[0].text + + if email and '@' in email: + + # split before and after @ + local_part, domain = email.split('@', 1) + + # make only before @ lowercase + local_part = local_part.lower() + + # update email + email_field[0].text = f"{local_part}@{domain}" + record_id = record.get('id', '') if record_id.startswith("base_industry_data."): root_res_partner.remove(record) diff --git a/automation_script/only_cleanup_script.py b/automation_script/only_cleanup_script.py index a6c6d2feee..6bc40ed450 100644 --- a/automation_script/only_cleanup_script.py +++ b/automation_script/only_cleanup_script.py @@ -16,11 +16,13 @@ import shutil import argparse import sys +import psycopg2 from pathlib import Path import re from ast import literal_eval from lxml import etree +from lxml import html # Setup logger # Create a Logger instance directly @@ -64,12 +66,12 @@ def __init__(self, ind_name, ind_category, db_name, module_path, destination_bas 'demo/website_page.xml', 'demo/account_analytic_plan.xml', 'demo/crm_team.xml', - 'data/uom.uom.xml', + 'data/uom_uom.xml', ] self.mandatory_files = { "/data/mail_message.xml": """ - + discuss.channel email @@ -78,7 +80,7 @@ def __init__(self, ind_name, ind_category, db_name, module_path, destination_bas 🚀 Get started with Odoo {Ind_name} @@ -221,7 +223,6 @@ def clean(self): 'sale_async_emails', 'snailmail_account', 'web_grid', - 'web_studio', 'social_push_notifications', 'appointment_sms', 'website_knowledge', @@ -258,6 +259,9 @@ def clean(self): elif current_dir.endswith('/ir_attachment/') and ext == "scss": self.get_relevant_scss_data(scss_content_list, root, file_name) + if not scss_content_list: + self.get_relevant_scss_data_from_db(scss_content_list) + # Generate SCSS function from collected theme data self.write_scss_function(destination_module_path, scss_content_list) @@ -278,11 +282,14 @@ def clean(self): # Retain only welcome article in the knowledge article self.clean_knowledge_article(destination_module_path) + # Transform knowledge article by extracting HTML content into a QWeb template + self.transform_knowledge_article(destination_module_path) + # Add demo payment provider if relevant module is present self.add_demo_payment_provider(destination_module_path, manifest_demo_file_list) - # Add immediate install function for the theme module in demo XML files - self.add_theme_immediate_install_function(destination_module_path) + # Add theme selection function using button_choose_theme in demo XML + self.add_button_choose_theme_function(destination_module_path) # Clean up sale order line records self.clean_sale_order_line_record(destination_module_path) @@ -299,6 +306,9 @@ def clean(self): # Clean up pos.payment.method records self.clean_pos_payment_method(destination_module_path) + # Clean and edit homepage view(ir_ui_view) + self.clean_homepage_view(destination_module_path) + # Update demo file order in manifest self.arrange_manifest_file(destination_module_path, manifest_demo_file_list) @@ -642,6 +652,70 @@ def remove_computed_fields(self, fields_info_dict, model_name, record, content): content = pattern_self_closing.sub('', content) return content + + def get_relevant_scss_data_from_db(self, scss_content_list): + conn = psycopg2.connect( + dbname=self.db_name, + ) + + cr = conn.cursor() + + query = """ + SELECT url, index_content + FROM ir_attachment + WHERE name IN ( + 'user_color_palette.scss', + 'user_values.scss', + 'user_theme_color_palette.scss' + ) + """ + + cr.execute(query) + + records = cr.fetchall() + + scss_pattern = re.compile(r'o-map-omit\(\(\s*(.*?)\s*(?://\s*--\s*hook\s*--)?\s*\)\)', re.DOTALL) + for file_name, raw in records: + clean_url = "/website/" + file_name.split('website/')[1] + + scss_content_dict = {} + + scss_content = raw.decode() if isinstance(raw, bytes) else raw + + scss_match = scss_pattern.search(scss_content) + + if scss_match: + lines = [] + + for line in scss_match.group(1).splitlines(): + if not line: + continue + + line = line.strip() + + if ":" in line: + key, value = line.split(":", 1) + + value = value.strip().rstrip(",") + if value == "NULL": + value = "null" + if not (value.startswith("'") and value.endswith("'")): + value = f"'{value}'" + + line = f"{key}: {value}," + + lines.append(line) + + inner_scss_content = "".join(lines) + + scss_content_dict['url'] = clean_url + scss_content_dict['inner_scss_content'] = inner_scss_content + + scss_content_list.append(scss_content_dict) + + + cr.close() + conn.close() def get_relevant_scss_data(self, scss_content_list, root, file_name): """ @@ -854,6 +928,10 @@ def remove_unused_ir_attachment_post(self, destination_module_path): record.remove(res_model[0]) if website_id: record.remove(website_id[0]) + existing = record.xpath("./field[@name='public']") + if not existing: + new_field = etree.Element("field", name="public", eval="True") + record.append(new_field) if key_field or name_field: # check key or name in ir_ui_view.xml file if not found store in list key = key_field[0].text if key_field else None @@ -1015,6 +1093,10 @@ def clean_knowledge_article(self, destination_module_path): if not record.xpath('.//field[@name="is_locked"]'): new_field = etree.Element("field", name="is_locked", eval="True") record.append(new_field) + + # Remove 'category' field if exists + if category_fields := record.xpath('.//field[@name="category"]'): + record.remove(category_fields[0]) else: # Remove all other records @@ -1023,6 +1105,47 @@ def clean_knowledge_article(self, destination_module_path): self.write_etree_content(path_knowledge_article, root_knowledge_article) return + def transform_knowledge_article(self, destination_module_path): + file_path = Path(destination_module_path + '/data/knowledge_article.xml') + + if not file_path.exists(): + return + + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() + + body_fields = root.xpath("//record[@id='welcome_article']/field[@name='body']") + if body_fields is None: + return + cnt = 0 + for body in body_fields: + + if body.text: + extracted_content = body.text + body.text = "" + template = etree.Element("template", id="welcome_article_body") + wrapper = html.fragment_fromstring(extracted_content, create_parent=True) + + # Remove attribute data-heading-link-id + for el in wrapper.xpath('//*[@data-heading-link-id]'): + del el.attrib['data-heading-link-id'] + + # Append all children + template.extend(wrapper) + + root.insert(cnt, template) + cnt+=2 + else: + continue + + body.text = etree.CDATA("") + + # THIS FIXES FORMATTING + etree.indent(root, space=" ") + + tree.write(file_path, encoding="utf-8", xml_declaration=True) + def check_website_sale_installed(self): # Prepare the JSON-RPC payload to search for the 'website_sale' module @@ -1099,6 +1222,88 @@ def add_require_depends(self, depends_list): depends_list = sorted(set(depends_list + new_depends)) return depends_list + def clean_homepage_view(self, destination_module_path): + path_ir_ui_view = Path(destination_module_path + '/demo/ir_ui_view.xml') + + if not path_ir_ui_view.exists(): + return + + root = self.get_etree_content(path_ir_ui_view) + old_id = None + + # Find homepage and update record id + records = root.xpath("//record") + for record in records: + t_nodes = record.xpath(".//t[@t-name='website.homepage']") + if t_nodes: + old_id = record.get("id") + record.set("id", "homepage") + break + + if old_id is None: + return + + # Update function reference + functions = root.xpath("//function") + extracted_functions = [] + + for func in functions: + values = func.xpath(".//value") + + for val in values: + eval_attr = val.get("eval") + + if eval_attr and f".{old_id}" in eval_attr: + new_eval = eval_attr.replace(f".{old_id}", ".homepage") + val.set("eval", new_eval) + + extracted_functions.append(func) + + # Update website_page.xml reference + path_website_page = Path(destination_module_path + '/demo/website_page.xml') + + if path_website_page.exists(): + page_root = self.get_etree_content(path_website_page) + + records = page_root.xpath("//record") + + for record in records: + view_fields = record.xpath("./field[@name='view_id']") + + ref_attr = view_fields[0].get("ref") if view_fields else None + + # check if ref matches old_id + if ref_attr and f"{old_id}" in ref_attr: + view_fields[0].set("ref", "homepage") + + # save updated file + self.write_etree_content(path_website_page, page_root) + + # Remove function from original file + for func in extracted_functions: + parent = func.getparent() + if parent is not None: + parent.remove(func) + + # Save cleaned ir_ui_view.xml + self.write_etree_content(path_ir_ui_view, root) + + # Add function to website_theme_apply.xml + if extracted_functions: + theme_apply_path = Path(destination_module_path + '/demo/website_theme_apply.xml') + + if theme_apply_path.exists(): + theme_root = self.get_etree_content(theme_apply_path) + else: + theme_root = etree.Element("odoo") + + for func in extracted_functions: + theme_root.append(func) + + self.write_etree_content(theme_apply_path, theme_root) + + return + def arrange_manifest_file(self, destination_module_path, manifest_demo_file_list): """ Finalizes the demo file arrangement and updates the __manifest__.py accordingly. @@ -1188,14 +1393,17 @@ def arrange_manifest_file(self, destination_module_path, manifest_demo_file_list return - def add_theme_immediate_install_function(self, destination_module_path): + def add_button_choose_theme_function(self, destination_module_path): """ - Adds an immediate install function for the theme module in the website_theme_apply.xml file. + Adds a theme selection function (button_choose_theme) to website_theme_apply.xml. + + This function extracts the theme_id from demo/website.xml of the given module. + It then generates a XML element that triggers theme selection using + the `button_choose_theme` method on `ir.module.module`. - This function reads the theme_id reference from the demo/website.xml file of the given module. - It then generates a XML element to trigger the immediate installation of the theme module. - The generated function block is appended inside the tag of demo/website_theme_apply.xml. - If the target XML file does not exist, it creates a new one with the required structure. + The generated function block is added inside the tag of + demo/website_theme_apply.xml. If the file does not exist, it creates + a new XML file with the required structure. Args: destination_module_path (str): The module directory name containing the demo folder with website.xml. @@ -1203,11 +1411,21 @@ def add_theme_immediate_install_function(self, destination_module_path): website_path = Path(destination_module_path + '/demo/' + 'website.xml') if website_path.exists(): etree_content = self.get_etree_content(website_path) - theme_id = etree_content.xpath("//field[@name='theme_id']")[0].get('ref') + theme_id = etree_content.xpath("//field[@name='theme_id']") if theme_id: # Build new entries for each SCSS customization - new_function = f"""""" + field_node = theme_id[0] + old_ref = field_node.get('ref') + new_function = f"""""" + + field_node.attrib.pop('ref', None) + field_node.set( + 'search', + f"[('id', '=', ref('{old_ref}', raise_if_not_found=False))]" + ) + + self.write_etree_content(website_path, etree_content) # Base structure if file does not exist base_xml = f""" @@ -1313,13 +1531,30 @@ def clean_hr_employee(self, destination_module_path): # remove the records only which starts with base_industry_data. def clean_res_partner(self, destination_module_path): """ - -remove the records only which starts with base_industry_data. + -remove the records only which starts with base_industry_data and transform emails to lowercase. """ path_res_partner = Path(destination_module_path + '/demo/' + 'res_partner.xml') if path_res_partner.exists(): root_res_partner = self.get_etree_content(path_res_partner) records = root_res_partner.xpath("//record") for record in records: + # find email field inside record + email_field = record.xpath("./field[@name='email']") + + if email_field: + email = email_field[0].text + + if email and '@' in email: + + # split before and after @ + local_part, domain = email.split('@', 1) + + # make only before @ lowercase + local_part = local_part.lower() + + # update email + email_field[0].text = f"{local_part}@{domain}" + record_id = record.get('id', '') if record_id.startswith("base_industry_data."): root_res_partner.remove(record)