# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import OrderedDict from contextlib import closing import io from lxml import etree from lxml.builder import E import os.path import zipfile from odoo import models from odoo.osv.expression import OR from odoo.tools import topological_sort # list of models to export (the order ensures that dependencies are satisfied) MODELS_TO_EXPORT = [ 'res.groups', 'report.paperformat', 'ir.model', 'ir.model.fields', 'ir.ui.view', 'ir.actions.act_window', 'ir.actions.act_window.view', 'ir.actions.report', 'mail.template', 'ir.actions.server', 'ir.ui.menu', 'ir.filters', 'base.automation', 'ir.model.access', 'ir.rule', 'ir.default', ] # list of fields to export by model FIELDS_TO_EXPORT = { 'base.automation': [ 'action_server_id', 'active', 'filter_domain', 'filter_pre_domain', 'last_run', 'on_change_field_ids', 'trg_date_id', 'trg_date_range', 'trg_date_range_type', 'trigger' ], 'ir.actions.act_window': [ 'binding_model_id', 'binding_type', 'binding_view_types', 'context', 'domain', 'filter', 'groups_id', 'help', 'limit', 'name', 'res_model', 'search_view_id', 'target', 'type', 'usage', 'view_id', 'view_mode' ], 'ir.actions.act_window.view': ['act_window_id', 'multi', 'sequence', 'view_id', 'view_mode'], 'ir.actions.report': [ 'attachment', 'attachment_use', 'binding_model_id', 'binding_type', 'binding_view_types', 'groups_id', 'model', 'multi', 'name', 'paperformat_id', 'report_name', 'report_type' ], 'ir.actions.server': [ 'binding_model_id', 'binding_type', 'binding_view_types', 'child_ids', 'code', 'crud_model_id', 'help', 'link_field_id', 'model_id', 'name', 'sequence', 'state' ], 'ir.filters': [ 'action_id', 'active', 'context', 'domain', 'is_default', 'model_id', 'name', 'sort' ], 'ir.model': ['info', 'is_mail_thread', 'is_mail_activity', 'model', 'name', 'state', 'transient'], 'ir.model.access': [ 'active', 'group_id', 'model_id', 'name', 'perm_create', 'perm_read', 'perm_unlink', 'perm_write' ], 'ir.model.fields': [ 'complete_name', 'compute', 'copied', 'depends', 'domain', 'field_description', 'groups', 'help', 'index', 'model', 'model_id', 'name', 'on_delete', 'readonly', 'related', 'relation', 'relation_field', 'relation_table', 'required', 'selectable', 'selection', 'size', 'state', 'store', 'tracking', 'translate', 'ttype' ], 'ir.rule': [ 'active', 'domain_force', 'groups', 'model_id', 'name', 'perm_create', 'perm_read', 'perm_unlink', 'perm_write' ], 'ir.ui.menu': ['action', 'active', 'groups_id', 'name', 'parent_id', 'sequence', 'web_icon'], 'ir.ui.view': [ 'active', 'arch', 'field_parent', 'groups_id', 'inherit_id', 'key', 'mode', 'model', 'name', 'priority', 'type' ], 'mail.template': [ 'auto_delete', 'body_html', 'copyvalue', 'email_cc', 'email_from', 'email_to', 'lang', 'model_id', 'model_object_field', 'name', 'null_value', 'partner_to', 'ref_ir_act_window', 'reply_to', 'report_name', 'report_template', 'scheduled_date', 'sub_model_object_field', 'sub_object', 'subject', 'use_default_to' ], 'res.groups': ['color', 'comment', 'implied_ids', 'name', 'share'], 'ir.default': ['field_id', 'condition', 'json_value'], } # list of relational fields to NOT export, by model FIELDS_NOT_TO_EXPORT = { 'base.automation': ['trg_date_calendar_id'], 'ir.actions.server': ['fields_lines', 'partner_ids'], 'ir.filter': ['user_id'], 'mail.template': ['attachment_ids', 'mail_server_id'], 'report.paperformat': ['report_ids'], 'res.groups': ['category_id', 'users'], } # The fields whose value must be wrapped in CDATA_FIELDS = [ ('ir.actions.server', 'code'), ('ir.model.fields', 'compute'), ('ir.rule', 'domain_force'), ('ir.actions.act_window', 'help'), ('ir.actions.server', 'help'), ('ir.model.fields', 'help') ] # The fields whose value is some XML content XML_FIELDS = [('ir.ui.view', 'arch')] def generate_archive(module, data): """ Returns a zip file containing the given module with the given data. """ with closing(io.BytesIO()) as f: with zipfile.ZipFile(f, 'w') as archive: for filename, content in generate_module(module, data): archive.writestr(os.path.join(module.name, filename), content) return f.getvalue() def generate_module(module, data): """ Return an iterator of pairs (filename, content) to put in the exported module. Returned filenames are local to the module directory. Only exports models in MODELS_TO_EXPORT. Groups exported data by model in separated files. The content of the files is yielded as an encoded bytestring (utf-8) """ get_xmlid = xmlid_getter() # 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 = set([u'web_studio']) skipped = [] # non-exported field values for model in MODELS_TO_EXPORT: # determine records to export for model model_data = data.filtered(lambda r: r.model == model) records = data.env[model].browse(model_data.mapped('res_id')).exists() if not records: continue # retrieve module and inter-record dependencies fields = [records._fields[name] for name in get_fields_to_export(records)] record_deps = OrderedDict.fromkeys(records, records.browse()) for record in records: xmlid = get_xmlid(record) module_name = xmlid.split('.', 1)[0] if module_name != module.name: # data depends on a record from another module depends.add(module_name) for field in fields: rel_records = get_relations(record, field) if not rel_records: continue for rel_record in rel_records: rel_xmlid = get_xmlid(rel_record, check=False) if rel_xmlid and rel_xmlid.split('.')[0] != module.name: # data depends on a record from another module depends.add(rel_xmlid.split('.')[0]) if rel_records._name == model: # fill in inter-record dependencies record_deps[record] |= rel_records if record._name == 'ir.model.fields' and record.ttype == 'monetary': # add a dependency on the currency field rel_record = record._get(record.model, 'currency_id') or record._get(record.model, 'x_currency_id') rel_xmlid = get_xmlid(rel_record, check=False) if rel_xmlid and rel_xmlid.split('.')[0] != module.name: # data depends on a record from another module depends.add(rel_xmlid.split('.')[0]) record_deps[record] |= rel_record # sort records to satisfy inter-record dependencies records = topological_sort(record_deps) # create the XML containing the generated record nodes nodes = [] for record in records: xmlid = get_xmlid(record) if xmlid.split('.', 1)[0] != '__export__': record_node, record_skipped = generate_record(record, get_xmlid) nodes.append(record_node) skipped.extend(record_skipped) root = E.odoo(*nodes) xml = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True) # add the XML file to the archive filename = '/'.join(['data', '%s.xml' % model.replace('.', '_')]) yield (filename, xml) filenames.append(filename) # yield a warning file to notify that some data haven't been exported if skipped: content = [ "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: content.append("Record: %s" % xmlid) content.append("Model: %s" % field.model_name) content.append("Field: %s" % field.name) content.append("Type: %s" % field.type) content.append("Value: %s (%s)" % (value, ', '.join("%r" % v.display_name for v in value))) content.append("") yield ('warning.txt', "\n".join(content)) # yield files '__manifest__.py' and '__init__.py' manifest = """# -*- coding: utf-8 -*- { 'name': %r, 'version': %r, 'category': 'Studio', 'description': %s, 'author': %r, 'depends': [%s ], 'data': [%s ], 'application': %s, 'license': %r, } """ % ( module.display_name, module.installed_version, 'u"""\n%s\n"""' % module.description, module.author, ''.join("\n %r," % d for d in sorted(depends - {'__export__'})), ''.join("\n %r," % f for f in filenames), module.application, module.license, ) manifest = manifest.encode('utf-8') yield ('__manifest__.py', manifest) yield ('__init__.py', b'') def get_relations(record, field): """ Return either a recordset that ``record`` depends on for ``field``, or a falsy value. """ if not record[field.name]: return if field.type in ('many2one', 'one2many', 'many2many', 'reference'): return record[field.name] if field.model_name == 'ir.model.fields': # Some fields (depends, related, relation_field) are of type char, but # refer to other fields that must be defined beforehand if field.name in ('depends', 'related'): # determine the fields that record depends on dep_fields = set() for dep_names in record[field.name].split(','): dep_model = record.env[record.model] for dep_name in dep_names.strip().split('.'): dep_field = dep_model._fields[dep_name] if not dep_field.automatic: dep_fields.add(dep_field) if dep_field.relational: dep_model = record.env[dep_field.comodel_name] # determine the 'ir.model.fields' corresponding to 'dep_fields' if dep_fields: return record.search(OR([ ['&', ('model', '=', dep_field.model_name), ('name', '=', dep_field.name)] for dep_field in dep_fields ])) elif field.name == 'relation_field': # The field 'relation_field' on 'ir.model.fields' is of type char, # but it refers to another field that must be defined beforehand return record.search([('model', '=', record.relation), ('name', '=', record.relation_field)]) # Fields 'res_model' and 'binding_model' on 'ir.actions.act_window' and 'model' # on 'ir.actions.report' are of type char but refer to models that may # be defined in other modules and those modules need to be listed as # dependencies of the exported module if field.model_name == 'ir.actions.act_window' and field.name in ('res_model', 'binding_model'): return record.env['ir.model']._get(record[field.name]) if field.model_name == 'ir.actions.report' and field.name == 'model': return record.env['ir.model']._get(record.model) def generate_record(record, get_xmlid): """ Return an etree Element for the given record, together with a list of skipped field values (fields in FIELDS_NOT_TO_EXPORT). """ xmlid = get_xmlid(record) skipped = [] # Create the record node record_node = E.record(id=xmlid, model=record._name, context="{'studio': True}") for name in get_fields_to_export(record): field = record._fields[name] try: record_node.append(generate_field(record, field, get_xmlid)) except MissingXMLID: # the field value contains a record without an xml_id; skip it skipped.append((xmlid, field, record[name])) # The record contains relational data that don't export, so register it in skipped for name in FIELDS_NOT_TO_EXPORT.get(record._name, ()): if record[name]: field = record._fields[name] skipped.append((xmlid, field, record[name])) return record_node, skipped def get_fields_to_export(record): fields_to_export = FIELDS_TO_EXPORT.get(record._name) if not fields_to_export: # deduce the fields_to_export from available data fields_to_export = set(record._fields.keys()) fields_to_export -= set(models.MAGIC_COLUMNS) fields_to_export.discard(record.CONCURRENCY_CHECK_FIELD) if FIELDS_NOT_TO_EXPORT.get(record._name): fields_to_export -= set(FIELDS_NOT_TO_EXPORT.get(record._name)) return fields_to_export def generate_field(record, field, get_xmlid): """ Serialize the value of ``field`` on ``record`` as an etree Element. """ value = record[field.name] if field.type == 'boolean': return E.field(name=field.name, eval=str(value)) elif field.type in ('many2one', 'reference'): if value: return E.field(name=field.name, ref=get_xmlid(value)) else: return E.field(name=field.name, eval=u"False") elif field.type in ('many2many', 'one2many'): return E.field( name=field.name, eval='[(6, 0, [%s])]' % ', '.join("ref('%s')" % get_xmlid(v) for v in value), ) else: if not value: return E.field(name=field.name, eval=u"False") elif (field.model_name, field.name) in CDATA_FIELDS: # Wrap value in