import ast import base64 from itertools import starmap from lxml import etree as ET from odoo import Command from odoo.addons.web_studio.controllers.export import ir_model_data_getter, generate_module, _clean_dependencies from odoo.addons.website.tools import MockRequest from odoo.osv import expression from odoo.tests.common import TransactionCase, tagged # ---------------------------------- HELPERS ---------------------------------- XMLPARSER = ET.XMLParser(remove_blank_text=True, strip_cdata=False, resolve_entities=False) IR_MODEL_INFO_FIELD = """""" def nodes_equal(n1, n2): if n1.tag != n2.tag: return False if n1.text != n2.text: return False if n1.tail != n2.tail: return False if n1.attrib != n2.attrib: return False if len(n1) != len(n2): return False if n1.tag == "field": # compare a tostring version, to check if CDATA sections are preserved n1_str = ET.tostring(n1) n2_str = ET.tostring(n2) if n1_str != n2_str: return False if n1.tag == "record": # n1 and n2 children order doesn't matter, sort them by tagname, attrib['name'] n1 = sorted(n1, key=lambda n: (n.tag, n.attrib.get("name"))) n2 = sorted(n2, key=lambda n: (n.tag, n.attrib.get("name"))) return all(starmap(nodes_equal, zip(n1, n2))) class StudioExportCase(TransactionCase): def setUp(self): super().setUp() self.model_data_getter = ir_model_data_getter(self.env['studio.export.wizard.data']) self._customizations = [] self._additional_models = self.env["studio.export.model"] self._additional_models.search([]).unlink() self.TestModel = self.env["test.studio_export.model1"].with_user(2) self.TestModel2 = self.env["test.studio_export.model2"].with_user(2) self.TestModel3 = self.env["test.studio_export.model3"].with_user(2) def create_customization(self, _model, **kwargs): Model = self.env[_model].with_context(studio=True) custo = Model.create(kwargs) self._customizations.append(custo) return custo def create_export_model(self, _model, **kwargs): IrModel = self.env["ir.model"] vals = {"model_id": IrModel._get_id(_model)} vals.update(kwargs) export_model = self.env["studio.export.model"].create(vals) self._additional_models |= export_model self.addCleanup(export_model.unlink) return export_model def get_xmlid(self, record): if self._current_wizard: self.model_data_getter = ir_model_data_getter(self._current_wizard.default_export_data | self._current_wizard.additional_export_data) return self.model_data_getter(record)._xmlid_for_export() def studio_export(self): # Get all customization data custo_domains = [ [("model", "=", custo._name), ("res_id", "=", custo.id)] for custo in self._customizations ] domain = expression.OR(custo_domains) domain = expression.AND([domain, [("studio", "=", True)]]) custo_data = self.env["ir.model.data"].search(domain) custo_data = self.env["studio.export.wizard.data"].create( [ {"model": d.model, "res_id": d.res_id, "studio": d.studio} for d in custo_data ] ) studio_module = self.env["ir.module.module"].get_studio_module() self._current_wizard = self.env["studio.export.wizard"].create( { "default_export_data": [Command.set(custo_data.ids)], "additional_models": [Command.set(self._additional_models.ids)], "include_additional_data": True, "include_demo_data": True, } ) export_info = self._current_wizard._get_export_info() content = generate_module(studio_module, export_info) return StudioExportAssertor(export_case=self, content=content) class StudioExportAssertor: def __init__(self, export_case, content) -> None: self.export_case = export_case self.exported_cache = {} self.content_iter = iter(content) def get_exported(self, name=None): """If name not found (or None) will iterate on all generated files Returns: If name is provided, returns the exported file content associated with that name. If name is not provided or not found, returns a dictionary of all exported files. """ with MockRequest(self.export_case.env): while name not in self.exported_cache: try: path, content = next(self.content_iter) except StopIteration: break if path.endswith(".xml"): self.exported_cache[path] = ET.fromstring(content, parser=XMLPARSER) elif path.endswith("__manifest__.py"): self.exported_cache[path] = ast.literal_eval(content.decode("utf-8")) else: self.exported_cache[path] = content return self.exported_cache[name] if name else self.exported_cache def assertFileContains(self, path, content): file = self.get_exported(path) self.export_case.assertEqual(file, content) def assertFileList(self, *filenames): """You can omit __init__.py and __manifest__.py""" filenames += ("__init__.py", "__manifest__.py") exported = self.get_exported() self.export_case.assertEqual(set(exported.keys()), set(filenames)) def assertManifest(self, **expected): exported = self.get_exported("__manifest__.py") for key, value in expected.items(): if key == "depends": with MockRequest(self.export_case.env): self.export_case.assertEqual( _clean_dependencies(set(exported["depends"] + value)), exported["depends"], ) else: self.export_case.assertEqual(exported[key], value) def assertXML(self, path, expected): root = self.get_exported(path) # parse expected then compare with nodes_equal expected = ET.fromstring(expected, parser=XMLPARSER) are_equal = nodes_equal(root, expected) message = "Both XMLs are equal" if not are_equal: tostring_opts = {"encoding": "unicode", "pretty_print": True} expected = ET.tostring(expected, **tostring_opts) actual = ET.tostring(root, **tostring_opts) message = "\nExpected:\n%s\nActual:\n%s" % (expected, actual) self.export_case.assertTrue(are_equal, message) # ----------------------------------- TESTS ----------------------------------- @tagged("-at_install", "post_install") class TestStudioExports(StudioExportCase): def test_export_customizations(self): custom_model = self.create_customization( "ir.model", name="Furnace Types", model="x_furnace_types" ) custom_field = self.create_customization( "ir.model.fields", name="x_studio_max_temp", complete_name="Max temperature", ttype="integer", model_id=custom_model.id, ) custom_view = self.create_customization( "ir.ui.view", name="Kanban view for x_furnace_types", model="x_furnace_types", type="kanban", arch=""" """, ) custom_action = self.create_customization( "ir.actions.act_window", name="Furnaces", res_model="x_furnace_types", view_mode="list,form,kanban", help="

This is your new action.

", ) custom_menu_1 = self.create_customization( "ir.ui.menu", name="My Furnaces", ) custom_menu_2 = self.create_customization( "ir.ui.menu", name="Furnaces Types", parent_id=custom_menu_1.id, action=f"ir.actions.act_window,{custom_action.id}", ) # Create a record to show that it is not exported # (appears neither in manifest nor in filelist) self.env[custom_model.model].create( {"x_name": "Austenitization", "x_studio_max_temp": 1200} ) export = self.studio_export() export.assertManifest( data=[ "data/ir_model.xml", "data/ir_model_fields.xml", "data/ir_ui_view.xml", "data/ir_actions_act_window.xml", "data/ir_ui_menu.xml", ], depends=["web_studio"], ) export.assertFileList( "data/ir_model.xml", "data/ir_model_fields.xml", "data/ir_ui_view.xml", "data/ir_actions_act_window.xml", "data/ir_ui_menu.xml", ) export.assertXML( "data/ir_model.xml", f""" {IR_MODEL_INFO_FIELD} x_furnace_types Furnace Types """, ) export.assertXML( "data/ir_model_fields.xml", f""" Max temperature integer X Studio Max Temp x_furnace_types x_studio_max_temp """, ) export.assertXML( "data/ir_ui_view.xml", f""" x_furnace_types Kanban view for x_furnace_types kanban """, ) export.assertXML( "data/ir_actions_act_window.xml", f""" This is your new action.

]]>
Furnaces x_furnace_types list,form,kanban
""", ) export.assertXML( "data/ir_ui_menu.xml", f""" My Furnaces Furnaces Types """, ) def test_export_customizations_with_export_model(self): custom_model = self.create_customization( "ir.model", name="Furnace Types", model="x_furnace_types" ) self.create_customization( "ir.model.fields", name="x_studio_max_temp", complete_name="Max temperature", ttype="integer", model_id=custom_model.id, ) CustomModel = self.env[custom_model.model].with_user(2).sudo() furnace_type = CustomModel.create( {"x_name": "Austenitization", "x_studio_max_temp": 1200} ) # Without export model, the custom model data are not exported export = self.studio_export() export.assertFileList("data/ir_model.xml", "data/ir_model_fields.xml") # With the export model, the custom model data is exported self.create_export_model(CustomModel._name) export = self.studio_export() export.assertFileList( "data/ir_model.xml", "data/ir_model_fields.xml", "data/x_furnace_types.xml", ) export.assertXML( "data/x_furnace_types.xml", f""" 1200 Austenitization """, ) def test_simple_export_model(self): export_model = self.create_export_model(self.TestModel._name) # Without record, the export_model has no effect export = self.studio_export() export.assertFileList() # Simple case some_record = self.TestModel.create({"name": "Some record"}) export = self.studio_export() export.assertFileList("data/test_studio_export_model1.xml") export.assertXML("data/test_studio_export_model1.xml", f""" Some record """) # Without updatable mode export_model.updatable = False export = self.studio_export() export.assertFileList("data/test_studio_export_model1.xml") export.assertXML("data/test_studio_export_model1.xml", f""" Some record """) # With is_demo_data mode, with updatable export_model.updatable = True export_model.is_demo_data = True export = self.studio_export() export.assertFileList("demo/test_studio_export_model1.xml") export.assertXML("demo/test_studio_export_model1.xml", f""" Some record """) # With is_demo_data mode, without updatable export_model.updatable = False export_model.is_demo_data = True export = self.studio_export() export.assertFileList("demo/test_studio_export_model1.xml") export.assertXML("demo/test_studio_export_model1.xml", f""" Some record """) def test_export_model_with_demo_data(self): some_record = self.TestModel.create({"name": "Some record"}) other_record = self.TestModel.create({"name": "Some other record"}) self.create_export_model(self.TestModel._name, is_demo_data=True) export = self.studio_export() export.assertFileList("demo/test_studio_export_model1.xml") export.assertXML( "demo/test_studio_export_model1.xml", f""" Some record Some other record """, ) def test_export_model_with_binary_field(self): some_record = self.TestModel.create( { "name": "Some record", "binary_data": base64.b64encode(b"My binary attachment"), } ) export_model = self.create_export_model(self.TestModel._name) # Without include_attachment export = self.studio_export() export.assertFileList( "data/test_studio_export_model1.xml", f"static/src/binary/test_studio_export_model1/{some_record.id}-binary_data", ) export.assertXML( "data/test_studio_export_model1.xml", f""" Some record """, ) # With include_attachment we have the same export result export_model.include_attachment = True export = self.studio_export() export.assertFileList( "data/test_studio_export_model1.xml", f"static/src/binary/test_studio_export_model1/{some_record.id}-binary_data", ) export.assertXML( "data/test_studio_export_model1.xml", f""" Some record """, ) def test_export_model_with_many2one_attachment(self): some_record = self.TestModel.create({"name": "Some record"}) attachment = self.env["ir.attachment"].create( { "name": "Some attachment", "datas": base64.b64encode(b"My attachment"), "res_model": self.TestModel._name, "res_id": some_record.id, "res_field": "attachment_id", } ) some_record.attachment_id = attachment self.create_export_model(self.TestModel._name, include_attachment=True) export = self.studio_export() export.assertFileList( "data/test_studio_export_model1.xml", "data/ir_attachment_pre.xml", f"static/src/binary/ir_attachment/{attachment.id}-Someattachment", ) export.assertManifest( depends=["test_web_studio"], data=["data/ir_attachment_pre.xml", "data/test_studio_export_model1.xml"], ) export.assertXML( "data/ir_attachment_pre.xml", f""" Some attachment """, ) export.assertXML( "data/test_studio_export_model1.xml", f""" Some record """, ) def test_export_model_with_one2many_attachment(self): some_record = self.TestModel.create({"name": "Some record"}) attachment1 = self.env["ir.attachment"].create( { "name": "Some attachment", "datas": base64.b64encode(b"My attachment"), "res_model": self.TestModel._name, "res_id": some_record.id, "res_field": "attachment_ids", } ) attachment2 = self.env["ir.attachment"].create( { "name": "Another attachment", "datas": base64.b64encode(b"My second attachment"), "res_model": self.TestModel._name, "res_id": some_record.id, "res_field": "attachment_ids", } ) some_record.attachment_ids = [Command.set([attachment1.id, attachment2.id])] self.create_export_model(self.TestModel._name, include_attachment=True) export = self.studio_export() export.assertFileList( "data/test_studio_export_model1.xml", "data/ir_attachment_post.xml", f"static/src/binary/ir_attachment/{attachment1.id}-Someattachment", f"static/src/binary/ir_attachment/{attachment2.id}-Anotherattachment", ) export.assertManifest( depends=["test_web_studio"], data=["data/test_studio_export_model1.xml", "data/ir_attachment_post.xml"], ) export.assertXML( "data/ir_attachment_post.xml", f""" Another attachment test.studio_export.model1 attachment_ids Some attachment test.studio_export.model1 attachment_ids """, ) export.assertXML( "data/test_studio_export_model1.xml", f""" Some record """, ) def test_empty_models_and_fields(self): # Test that models without records do not export any data # and empty fields are not exported model2_record1 = self.TestModel2.create({ "name": "Some Record" }) model2_record2 = self.TestModel2.create({ "name": "", "model2_id": model2_record1.id }) self.create_export_model(self.TestModel2._name) self.create_export_model(self.TestModel3._name) export = self.studio_export() export.assertManifest( data=[ "data/test_studio_export_model2.xml", ], ) export.assertFileList( "data/test_studio_export_model2.xml", ) export.assertXML( "data/test_studio_export_model2.xml", f""" Some Record """, ) def test_export_data_related_to_demo(self): # Test that master data (non demo) does not export fields related # to demo records, but data records related to demo are also exported # as demo with only the fields related to said demo records. model3_record = self.TestModel3.create({"name": "Some record"}) model2_record = self.TestModel2.create({ "name": "Some other record", "model3_id": model3_record.id }) self.create_export_model(self.TestModel2._name, is_demo_data=False) self.create_export_model(self.TestModel3._name, is_demo_data=True) export = self.studio_export() export.assertFileList( "data/test_studio_export_model2.xml", "demo/test_studio_export_model2.xml", "demo/test_studio_export_model3.xml", ) export.assertXML( "data/test_studio_export_model2.xml", f""" Some other record """, ) export.assertXML( "demo/test_studio_export_model3.xml", f""" Some record """, ) export.assertXML( "demo/test_studio_export_model2.xml", f""" """, ) def test_export_dependencies_order(self): # Test that files and records order respects dependencies model3_record = self.TestModel3.create({"name": "Some record"}) model2_record = self.TestModel2.create({ "name": "Some other record", "model3_id": model3_record.id }) model2b_record = self.TestModel2.create({ "name": "Some other record", "model2_id": model2_record.id, "model3_id": model3_record.id }) self.create_export_model(self.TestModel2._name) self.create_export_model(self.TestModel3._name) export = self.studio_export() export.assertManifest( data=[ "data/test_studio_export_model3.xml", "data/test_studio_export_model2.xml", ], ) export.assertFileList( "data/test_studio_export_model3.xml", "data/test_studio_export_model2.xml", ) export.assertXML( "data/test_studio_export_model3.xml", f""" Some record """, ) export.assertXML( "data/test_studio_export_model2.xml", f""" Some other record Some other record """, ) def test_export_handles_circular_dependencies(self): # Test that models circular dependencies appear in warning.txt # and only if some records causes it model3_record = self.TestModel3.create({ "name": "Record 3", }) model2_record = self.TestModel2.create({ "name": "Record 2", "model3_id": model3_record.id }) model1_record = self.TestModel.create({ "name": "Record 1", "model2_id": model2_record.id }) model3_record.update({"model1_id": model1_record.id}) self.create_export_model(self.TestModel._name) self.create_export_model(self.TestModel2._name) self.create_export_model(self.TestModel3._name) export = self.studio_export() export.assertFileList( "warnings.txt", "data/test_studio_export_model1.xml", "data/test_studio_export_model2.xml", "data/test_studio_export_model3.xml", ) export.assertXML( "data/test_studio_export_model1.xml", f""" Record 1 """, ) export.assertXML( "data/test_studio_export_model2.xml", f""" Record 2 """, ) export.assertXML( "data/test_studio_export_model3.xml", f""" Record 3 """, ) export.assertFileContains( "warnings.txt", f"""Found 1 circular dependencies (you may have to change data loading order to avoid issues when importing): (data) {self.TestModel._name} -> {self.TestModel2._name} -> {self.TestModel3._name} -> {self.TestModel._name} """, ) def test_export_abstract_actions_with_proper_types(self): WindowActions = self.env["ir.actions.act_window"].with_user(2) window_action = WindowActions.create({ "name": "Test action", "type": "ir.actions.act_window", "res_model": self.TestModel._name, "view_mode": "form", "target": "new", }) URLActions = self.env["ir.actions.act_url"].with_user(2) url_action = URLActions.create({ "name": "Test action", "type": "ir.actions.act_url", "url": "http://odoo.com", }) self.create_export_model("ir.actions.actions", domain=[("id", "in", [window_action.id, url_action.id])]) export = self.studio_export() export.assertFileList( "data/ir_actions_act_window.xml", "data/ir_actions_act_url.xml", ) export.assertXML( "data/ir_actions_act_window.xml", f""" Test action {self.TestModel._name} form new """, ) export.assertXML( "data/ir_actions_act_url.xml", f""" Test action Test action """, )