# Part of Odoo. See LICENSE file for full copyright and licensing details. import binascii import functools import pprint import textwrap from base64 import b64decode from collections import Counter, OrderedDict from lxml import etree from lxml.builder import E from odoo import models from odoo.fields import Domain from odoo.tools import topological_sort # The fields whose value is some XML content XML_FIELDS = [('ir.ui.view', 'arch')] class ModelDataGetter: def __init__(self, data=None): self.reset_cache(data) def reset_cache(self, data=None): # {(model_name, record_id): ir_model_data_record(s)} self._cache = data.grouped(lambda d: (d.model, d.res_id)) if data else {} def __call__(self, record): key = (record._name, record.id) if key not in self._cache or not self._cache[key]: # prefetch when possible for data in record.env['ir.model.data'].sudo().search( [('model', '=', record._name), ('res_id', 'in', list(record._prefetch_ids))], order='id', ): key_data = (data.model, data.res_id) if key_data not in self._cache: # Only one record in the cache self._cache[key_data] = data if key not in self._cache: raise MissingXMLID(record) return self._cache[key] class MissingXMLID(Exception): def __init__(self, record): super().__init__("Missing XMLID: %s (%s)" % (record, record.display_name)) class StudioExportSerializer: def __init__(self, env, module, export_info): self.env = env self.module = module data, to_export, circular_dependencies = export_info self.data = data self.to_export = to_export self.circular_dependencies = circular_dependencies self.has_website = env['ir.module.module'].search_count([('state', '=', 'installed'), ('name', '=', 'website')]) == 1 self.utils = StudioExportUtils(env, module, data) self.filepaths = [] self.demo_sale_orders = None self.website_themes = [] def serialize(self): depends = set(self.module.mapped('dependencies_id.name')) # start with module's dependencies self.utils.path_counter.clear() self.warnings = [] if self.circular_dependencies: circ_lines = "\n".join([ f"({'demo' if is_demo else 'data'}) {' -> '.join(dep)}" for (is_demo, dep) in self.circular_dependencies ]) self.warnings.append( f"Found {len(self.circular_dependencies)} circular dependencies (you may have to change data loading order to avoid issues when importing):\n" f"{circ_lines}\n" ) # Generate xml files for the data to export has_skipped_fields_warnings = False for model, records, fields, no_update, path_info in self.to_export: (files, new_deps, skipped_relations) = self._serialize_model( model=model, records=records, fields=fields, no_update=no_update, path_info=path_info, ) if files is not None: depends.update(new_deps) yield from files if skipped_relations: if not has_skipped_fields_warnings: has_skipped_fields_warnings = True self.warnings.append( "The following relational data haven't been exported because they either refer\n" "to a model that Studio doesn't export, or have no XML id:\n" ) for xmlid, field, value in skipped_relations: self.warnings.append(textwrap.dedent(f""" Record: {xmlid}, Model: {field.model_name}, Field: {field.name}, Type: {field.type}, Value: {value} ({ ', '.join(repr(v.display_name) for v in value) if isinstance(value, models.BaseModel) else f"DB id: {value}" }) """)) yield from self._get_last_files(depends) def _serialize_model(self, model, records, fields, no_update, path_info): records_to_export, binary_files, new_deps = self.utils.prepare_records_to_export(model, records, fields) default_get_data = records_to_export[0].browse().default_get(fields) # create the XML containing the generated record nodes nodes = self._get_pre_nodes(model, records_to_export) skipped_fields = [] for record in records_to_export: record_node, skipped_relations = self._serialize_record(record, fields, default_get_data) if record_node is not None: nodes.append(record_node) skipped_fields.extend(skipped_relations) nodes.extend(self._get_post_nodes(model, records)) if not nodes: return None, {}, skipped_fields root = E.odoo(*nodes, noupdate="1") if no_update else E.odoo(*nodes) content = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True) (group, suffix) = path_info filepath = self.utils.get_next_file_path(group, model, suffix) self.filepaths.append(filepath) self._prepare_pending_files(group, model, new_deps) return [(filepath, content)] + binary_files, new_deps, skipped_fields def _serialize_record(self, record, fields, 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). Returns: tuple: A tuple containing the '' etree Element (or None if no fields are exported) and a list of skipped field values. """ record_data = self.utils.get_model_data(record) exportid = self.utils.get_xmlid(record) skipped_relations = [] # 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, 'update_product_template_attribute_values': False}) if record._name == 'worksheet.template': context.update({'worksheet_no_generation': True}) if exportid.startswith('website.configurator_'): exportid = exportid.replace('website.configurator_', 'configurator_') node_attrs = {"id": exportid, "model": record._name} if context: node_attrs["context"] = str(context) if self.module.name != self.utils.get_module_name(record): node_attrs["forcecreate"] = "1" fields_nodes = [] for name in fields: field = record._fields[name] field_element = None try: field_element = self._serialize_field(record, field, default_get_data) except MissingXMLID: # the field value contains a record without an xml_id; skip it skipped_relations.append((exportid, field, record[name])) if field_element is not None: fields_nodes.append(field_element) return E.record(*fields_nodes, **node_attrs) if fields_nodes else None, skipped_relations def _serialize_field(self, record, field, default_get_data): """ Serialize the value of ``field`` on ``record`` as an etree Element. Returns: etree.Element: The serialized field value, or None if the field value is empty or equal to the default value. """ 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 self.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'): return E.field(name=field.name, eval=str(value)) elif field.type == 'properties': return E.field(name=field.name, eval=str(field.convert_to_export(value, record))) 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 = self.utils.get_xmlid(reference_value) 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 = [self.utils.get_xmlid(v) 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/' + self.utils.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 = self.utils.get_xmlid(value) return E.field(name=field.name, ref=xmlid) elif field.type in ('html', 'text'): # Wrap value in