Odoo18-Base/extra-addons/web_studio/controllers/export.py

526 lines
21 KiB
Python

# -*- 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 <![CDATA[]] to preserve it to be interpreted as XML markup, if any
node = E.field(name=field.name)
node.text = etree.CDATA(str(value))
return node
else:
return E.field(str(value), name=field.name)
# ===================================== Utils ===================================
def _clean_dependencies(input_deps):
"""Return the minimal set of modules that ``depends`` depends on."""
all_deps = request.env["ir.module.module.dependency"].all_dependencies(input_deps)
deep_deps = dict()
def get_deep_depends(module_name):
"""Return a set of all modules that ``module_name`` will install."""
nonlocal deep_deps
if module_name in deep_deps:
return deep_deps[module_name]
# initial case
deep_deps[module_name] = set()
# recursive case
for dep in all_deps.get(module_name, []):
deep_deps[module_name] |= {dep, *get_deep_depends(dep)}
return deep_deps[module_name]
for name in all_deps:
get_deep_depends(name)
# mods_deps = {item for sublist in zip(*all_deps.values()) for item in sublist}
output_deps = set(input_deps)
for mod, deps in deep_deps.items():
if mod in input_deps:
to_remove = deps - {mod}
output_deps -= to_remove
return sorted(output_deps)
def _get_binary_field_file_name(field, record):
binary_filename = "%s-%s" % (record.id, field.name)
if field.model_name == 'ir.attachment':
binary_filename = "%s-%s" % (record.id, record.name.replace('/', '_').replace(' ', ''))
return f"static/src/binary/{field.model_name.replace('.', '_')}/{binary_filename}"
def _get_module_name(xmlid):
if xmlid.startswith('base.module_'):
# len('base.module_') == 12
return xmlid[12:]
if not '.' in xmlid:
return 'studio_customization'
return xmlid.split('.', 1)[0]
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 _prepare_records_to_export(module, model, records, fields_to_export, get_model_data):
"""Returns
- A sorted list of records that satisfies inter-record dependencies
- Additional module dependencies
- Additional binary files
"""
depends = set()
def add_dependency(module_name):
if module_name and module_name != module.name and module_name != '__export__':
depends.add(module_name)
binary_files = []
fields = [records._fields[name] for name in fields_to_export]
record_deps = OrderedDict.fromkeys(records, records.browse())
for record in records:
record_data = get_model_data(record)
exportid = record_data._xmlid_for_export()
module_name = _get_module_name(exportid)
# data depends on a record from another module
add_dependency(record._original_module) # module that first created the record's model
add_dependency(record._module) # module that last extended the record's model
add_dependency(module_name) # module from which the record was defined
for field in fields:
for m in field._modules:
add_dependency(m)
# create files for binary fields
if field.type == 'binary' and record[field.name]:
value = record[field.name]
binary_data = b64decode(value)
binary_files.append((_get_binary_field_file_name(field, record), binary_data))
# handle relational fields
rel_records = _get_relations(record, field)
if not rel_records:
continue
for rel_record in rel_records:
try:
rel_xmlid = get_model_data(rel_record)._xmlid_for_export()
except MissingXMLID:
# skip records that don't have an xmlid,
# as they won't be exported and will
# end up in the warning.txt file anyway
continue
add_dependency(_get_module_name(rel_xmlid))
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')
if rel_record:
rel_xmlid = get_model_data(rel_record)._xmlid_for_export()
add_dependency(_get_module_name(rel_xmlid))
record_deps[record] |= rel_record
# sort records to satisfy inter-record dependencies
records = topological_sort(record_deps)
return records, depends, binary_files
def _has_website():
return request.env['ir.module.module'].search_count([('state', '=', 'installed'), ('name', '=', 'website')]) == 1
def ir_model_data_getter(data):
""" Returns a function that returns the data (either ir.model.data or studio.export.wizard.data record) of a given record """
# {(model_name, record_id): ir_model_data_record(s)}
cache = data.grouped(lambda d: (d.model, d.res_id))
def get(record):
""" Return the ir_model_data linked to the ``record``.
Raise a ``MissingXMLID`` if ir_model_data does not exist.
"""
key = (record._name, record.id)
if key not in cache or not 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 cache: # Only one record in the cache
cache[key_data] = data
if key not in cache:
raise MissingXMLID(record)
return cache[key]
return get
class MissingXMLID(Exception):
def __init__(self, record):
super(MissingXMLID, self).__init__("Missing XMLID: %s (%s)" % (record, record.display_name))