# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import io import zipfile from collections import OrderedDict from lxml import etree from lxml.builder import E from odoo import models from odoo.http import request from odoo.osv.expression import OR from odoo.tools import topological_sort from base64 import b64decode # The fields whose value is some XML content XML_FIELDS = [('ir.ui.view', 'arch')] def generate_archive(module, export_info): """ Returns a zip file containing the given module with the given data. Returns: bytes: A byte string containing the zip file. """ with io.BytesIO() as f: with zipfile.ZipFile(f, 'w') as archive: for filename, content in generate_module(module, export_info): archive.writestr(module.name + '/' + filename, content) return f.getvalue() def generate_module(module, export_info): """ Return an iterator of pairs (filename, content) to put in the exported module. Returned filenames are local to the module directory. Groups exported data by model in separated files. The content of the files is yielded as an encoded bytestring (utf-8) Yields: tuple: A tuple containing the filename and content. """ has_website = _has_website() # Generate xml files and yield them filenames = [] # filenames to include in the module to export # depends contains module dependencies of the module to export, as a result # we add web_studio by default to deter importing studio customizations # in community databases depends = {'web_studio'} skipped_fields = [] # non-exported field values # Generate xml files for the data to export data, export, circular_dependencies = export_info model_data_getter = ir_model_data_getter(data) for model, filepath, records, fields, no_update in export: (xml, binary_files, new_dependencies, skipped) = _serialize_model(module, model, records, fields, no_update, has_website, model_data_getter) if xml is not None: yield from binary_files yield (filepath, xml) filenames.append(filepath) depends.update(new_dependencies) skipped_fields.extend(skipped) # SPECIFIC: Confirm demo sale orders if 'demo/sale_order.xml' in filenames: filepath = 'demo/sale_order_confirm.xml' sale_orders_data = data.filtered(lambda d: d.model == "sale.order") sale_orders = request.env["sale.order"].browse(sale_orders_data.mapped("res_id")) xmlids = [model_data_getter(so)._xmlid_for_export() for so in sale_orders if so.state not in ('cancel', 'draft')] nodes = [] # Update sale order stages nodes.extend([ etree.Comment("Update sale order stages"), E.function( model="sale.order", name="action_confirm", eval="[[%s]]" % ','.join("ref('%s')" % xmlid for xmlid in xmlids) ) ]) root = E.odoo(*nodes) xml = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True) yield (filepath, xml) filenames.append(filepath) # SPECIFIC: Apply website theme if needed themes = [d for d in depends if d.startswith('theme_')] if themes: filepath = 'demo/website_theme_apply.xml' fns = [ E.function( E.value( model="ir.module.module", eval=f"obj().env['ir.module.module'].search([('name', '=', '{theme}')]).ids" ), E.value( model="ir.module.module", eval="obj().env.ref('website.default_website')" ), model="ir.module.module", name="_theme_load", context="{'apply_new_theme': True}" ) for theme in themes ] # comment all but the first theme comments = [ etree.Comment( etree.tostring(fn, pretty_print=True, encoding='UTF-8') ) for fn in fns[1:] ] nodes = [fns[0], *comments] root = E.odoo(*nodes) xml = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True) yield (filepath, xml) # this demo file should be before demo/ir_ui_view.xml index = filenames.index('demo/ir_ui_view.xml') filenames.insert(index, filepath) # yield a warning file to notify circular dependencies and that some data haven't been exported warnings = [] if circular_dependencies: circular_warnings = [ f"({'demo' if is_demo else 'data'}) {' -> '.join(dep)}" for (is_demo, dep) in circular_dependencies ] warnings.extend([ f"Found {len(circular_dependencies)} circular dependencies (you may have to change data loading order to avoid issues when importing):", *circular_warnings, "", ]) if skipped_fields: warnings.extend([ "The following relational data haven't been exported because they either refer", "to a model that Studio doesn't export, or have no XML id:", "", ]) for xmlid, field, value in skipped_fields: warnings.extend([ "Record: %s" % xmlid, "Model: %s" % field.model_name, "Field: %s" % field.name, "Type: %s" % field.type, "Value: %s (%s)" % (value, isinstance(value, models.BaseModel) and ', '.join("%r" % v.display_name for v in value) or "DB id: %s" % (value)), "", ]) if warnings: yield ('warnings.txt', "\n".join(warnings)) # yield files '__manifest__.py' and '__init__.py' demo_files = [f for f in filenames if f.startswith('demo/')] manifest = """# -*- coding: utf-8 -*- { 'name': %r, 'version': %r, 'category': 'Studio', 'description': %s, 'author': %r, 'depends': [%s ], 'data': [%s ], %s'license': %r, } """ % ( module.display_name, module.installed_version, 'u"""\n%s\n"""' % module.description, module.author, ''.join("\n %r," % d for d in _clean_dependencies(depends)), ''.join("\n %r," % f for f in filenames if f.startswith('data/')), "'demo': [%s\n ],\n " % ''.join("\n %r," % f for f in demo_files) if demo_files else '', module.license, ) manifest = manifest.encode('utf-8') yield ('__manifest__.py', manifest) yield ('__init__.py', b'') # ============================== Serialization ================================== def _serialize_model(module, model, records, fields_to_export, no_update, has_website, get_model_data): records_to_export, depends, binary_files = _prepare_records_to_export(module, model, records, fields_to_export, get_model_data) # create the XML containing the generated record nodes nodes = [] # SPECIFIC: unlink the default main menu from the website if needed if model == 'website.menu' and any(r['url'] == '/default-main-menu' for r in records): # unlink the default menu from the website, in order to add our own nodes.append( E.function( E.value( model="website.menu", eval="obj().search([('website_id', '=', ref('website.default_website')), ('url', '=', '/default-main-menu')]).id" ), model="website.menu", name="unlink" ) ) if records_to_export: default_get_result = records_to_export[0].browse().default_get(fields_to_export) skipped_records = [] for record in records_to_export: record_node, record_skipped = _serialize_record(module, model, record, fields_to_export, has_website, get_model_data, default_get_result) if record_node is not None: nodes.append(record_node) skipped_records.extend(record_skipped) # SPECIFIC: replace website pages arch if needed if model == 'ir.ui.view' and has_website: website_views = filter(lambda r: r['website_id'] and r['key'].startswith('website.') and r['create_uid'].id == 1, records_to_export) for view in website_views: exportid = get_model_data(view)._xmlid_for_export() nodes.append( E.function( E.value( model="ir.ui.view", eval="obj().env['website'].with_context(website_id=obj().env.ref('website.default_website').id).viewref('%s').id" % view['key'] ), E.value( model="ir.ui.view", eval="{'arch': obj().env.ref('%s').arch}" % exportid ), model="ir.ui.view", name="write" ) ) if nodes: root = E.odoo(*nodes, noupdate="1") if no_update else E.odoo(*nodes) xml = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True) else: xml = None return xml, binary_files, depends, skipped_records def _serialize_record(module, model, record, fields_to_export, has_website, get_model_data, default_get_data): """ Return an etree Element for the given record, together with a list of skipped field values (field value references a record without external id). """ record_data = get_model_data(record) exportid = record_data._xmlid_for_export() skipped = [] # Create the record node context = {} if record_data.studio: context.update({'studio': True}) if record._name in ('product.template', 'product.template.attribute.line'): context.update({'create_product_product': False}) if record._name == 'worksheet.template': context.update({'worksheet_no_generation': True}) if exportid.startswith('website.configurator_'): exportid = exportid.replace('website.configurator_', 'configurator_') kwargs = {"id": exportid, "model": record._name} if context: kwargs["context"] = str(context) if module.name != _get_module_name(exportid): kwargs["forcecreate"] = "1" fields_nodes = [] for name in fields_to_export: field = record._fields[name] field_element = None try: field_element = _serialize_field(record, field, has_website, get_model_data, default_get_data) except MissingXMLID: # the field value contains a record without an xml_id; skip it skipped.append((exportid, field, record[name])) if field_element is not None: fields_nodes.append(field_element) return E.record(*fields_nodes, **kwargs) if fields_nodes else None, skipped def _serialize_field(record, field, has_website, get_model_data, default_get_data): """ Serialize the value of ``field`` on ``record`` as an etree Element. """ default_value = default_get_data.get(field.name) value = record[field.name] if (not value and not default_value) or field.convert_to_cache(value, record) == field.convert_to_cache(default_value, record): return # SPECIFIC: make a unique key for ir.ui.view.key in case of website_id if has_website and field.name == 'key' and record._name == 'ir.ui.view' and record.website_id: value = f"studio_customization.{value}" if field.type in ('boolean', 'properties_definition', 'properties'): return E.field(name=field.name, eval=str(value)) elif field.type == 'many2one_reference': reference_model = record[field.model_field] reference_value = reference_model and record.env[reference_model].browse(value) or False xmlid = get_model_data(reference_value)._xmlid_for_export() if reference_value: return E.field(name=field.name, ref=xmlid) else: return E.field(name=field.name, eval="False") elif field.type in ('many2many', 'one2many'): xmlids = [get_model_data(v)._xmlid_for_export() for v in value] return E.field( name=field.name, eval='[(6, 0, [%s])]' % ', '.join("ref('%s')" % xmlid for xmlid in xmlids), ) if not value: return E.field(name=field.name, eval="False") elif (field.model_name, field.name) in XML_FIELDS: # Use an xml parser to remove new lines and indentations in value parser = etree.XMLParser(remove_blank_text=True, resolve_entities=False) return E.field(etree.XML(value, parser), name=field.name, type='xml') elif field.type == 'binary': return E.field(name=field.name, type="base64", file='studio_customization/' + _get_binary_field_file_name(field, record)) elif field.type == 'datetime': return E.field(field.to_string(value), name=field.name) elif field.type in ('many2one', 'reference'): xmlid = get_model_data(value)._xmlid_for_export() return E.field(name=field.name, ref=xmlid) elif field.type in ('html', 'text'): # Wrap value in