合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import export
from . import ir_http
from . import main
from . import report

View File

@@ -0,0 +1,373 @@
# -*- 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[]]>
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 <![CDATA[]] to preserve it to be interpreted as XML markup
node = E.field(name=field.name)
node.text = etree.CDATA(str(value))
return node
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)
return E.field(etree.XML(value, parser), name=field.name, type='xml')
else:
return E.field(str(value), name=field.name)
def xmlid_getter():
""" Return a function that returns the xml_id of a given record. """
cache = {}
def get(record, check=True):
""" Return the xml_id of ``record``.
Raise a ``MissingXMLID`` if ``check`` is true and xml_id is empty.
"""
try:
res = cache[record]
except KeyError:
# prefetch when possible
records = record.browse(record._prefetch_ids)
for rid, val in records.get_external_id().items():
cache[record.browse(rid)] = val
res = cache[record]
if check and not res:
raise MissingXMLID(record)
return res
return get
class MissingXMLID(Exception):
def __init__(self, record):
super(MissingXMLID, self).__init__("Missing XMLID: %s (%s)" % (record, record.display_name))

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.http import request
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
def session_info(self):
result = super(IrHttp, self).session_info()
if result['is_system']:
# necessary keys for Studio
result['dbuuid'] = request.env['ir.config_parameter'].sudo().get_param('database.uuid')
result['multi_lang'] = len(request.env['res.lang'].get_installed()) > 1
return result

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
from lxml import etree
from odoo import http, _
from odoo.http import request
from odoo.addons.web_studio.controllers import main
from odoo.exceptions import ValidationError, UserError
class WebStudioReportController(main.WebStudioController):
@http.route('/web_studio/create_new_report', type='json', auth='user')
def create_new_report(self, model_name, layout):
if layout == 'web.basic_layout':
arch_document = etree.fromstring("""
<t t-name="studio_report_document">
<div class="page"/>
</t>
""")
else:
arch_document = etree.fromstring("""
<t t-name="studio_report_document">
<t t-call="%(layout)s">
<div class="page"/>
</t>
</t>
""" % {'layout': layout})
view_document = request.env['ir.ui.view'].create({
'name': 'studio_report_document',
'type': 'qweb',
'arch': etree.tostring(arch_document, encoding='utf-8', pretty_print=True),
})
new_view_document_xml_id = view_document.get_external_id()[view_document.id]
view_document.name = '%s_document' % new_view_document_xml_id
view_document.key = '%s_document' % new_view_document_xml_id
if layout == 'web.basic_layout':
arch = etree.fromstring("""
<t t-name="studio_main_report">
<t t-foreach="docs" t-as="doc">
<t t-call="%(layout)s">
<t t-call="%(document)s_document"/>
<p style="page-break-after: always;"/>
</t>
</t>
</t>
""" % {'layout': layout, 'document': new_view_document_xml_id})
else:
arch = etree.fromstring("""
<t t-name="studio_main_report">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="%(document)s_document"/>
</t>
</t>
</t>
""" % {'document': new_view_document_xml_id})
view = request.env['ir.ui.view'].create({
'name': 'studio_main_report',
'type': 'qweb',
'arch': etree.tostring(arch, encoding='utf-8', pretty_print=True),
})
# FIXME: When website is installed, we need to set key as xmlid to search on a valid domain
# See '_view_obj' in 'website/model/ir.ui.view'
view.name = new_view_document_xml_id
view.key = new_view_document_xml_id
model = request.env['ir.model']._get(model_name)
report = request.env['ir.actions.report'].create({
'name': _('%s Report', model.name),
'model': model.model,
'report_type': 'qweb-pdf',
'report_name': view.name,
})
# make it available in the print menu
report.create_action()
return {
'id': report.id,
}
@http.route('/web_studio/print_report', type='json', auth='user')
def print_report(self, report_name, record_id):
report = request.env['ir.actions.report']._get_report_from_name(report_name)
return report.report_action(record_id)
@http.route('/web_studio/edit_report', type='json', auth='user')
def edit_report(self, report_id, values):
report = request.env['ir.actions.report'].browse(report_id)
if report:
if 'attachment_use' in values:
if values['attachment_use']:
values['attachment'] = "'%s'" % report.name
else:
# disable saving as attachment altogether
values['attachment'] = False
if 'groups_id' in values:
values['groups_id'] = [(6, 0, values['groups_id'])]
if 'display_in_print' in values:
if values['display_in_print']:
report.create_action()
else:
report.unlink_action()
values.pop('display_in_print')
report.write(values)
return report.read()
@http.route('/web_studio/read_paperformat', type='json', auth='user')
def read_paperformat(self, report_id):
report = request.env['ir.actions.report'].browse(report_id)
return report.get_paperformat().read()
@http.route('/web_studio/get_widgets_available_options', type='json', auth='user')
def get_widgets_available_options(self):
fields = dict()
records = request.env['ir.model'].search([('model', 'like', 'ir.qweb.field.%')])
for record in records:
fields[record.model[14:]] = request.env[record.model].get_available_options()
return fields
@http.route('/web_studio/get_report_views', type='json', auth='user')
def get_report_views(self, report_name, record_id=None):
if record_id is None:
raise UserError(_("To edit this document please create a record first"))
loaded = set()
views = {}
def get_report_view(key):
view = request.env['ir.ui.view'].search([
('key', '=', key),
('type', '=', 'qweb'),
('mode', '=', 'primary'),
], limit=1)
if not view:
raise UserError(_("No view found for the given report!"))
return view
def process_template_groups(element):
""" `get_template` only returns the groups names but we also need
need their id and display name in Studio to edit them (many2many
tags widget). These data are thus added on the node.
This processing is quite similar to what has been done on views.
"""
for node in element.iter():
if node.get('groups'):
request.env['ir.ui.view'].set_studio_groups(node)
def load_arch(view_name):
if view_name in loaded:
return
loaded.add(view_name)
view = get_report_view(view_name)
studio_view = self._get_studio_view(view)
element = request.env['ir.qweb'].with_context(full_branding=True)._get_template(view.id)[0]
process_template_groups(element)
views[view.id] = {
'arch': etree.tostring(element),
'key': view.key,
'studio_arch': studio_view.arch_db or "<data/>",
'studio_view_id': studio_view.id,
'view_id': view.id,
}
for node in element.getroottree().findall("//*[@t-call]"):
tcall = node.get("t-call")
if '{' in tcall:
# this t-call value is dynamic (e.g. t-call="{{company.tmp}})
# so its corresponding view cannot be read
# this template won't be returned to the Editor so it won't
# be customizable
continue
load_arch(tcall)
return view.id
load_arch(report_name)
main_view_id = get_report_view(report_name).id
report_html = self._test_report(report_name, record_id)
return {
'report_html': report_html and report_html[0],
'main_view_id': main_view_id,
'views': views,
}
@http.route('/web_studio/edit_report_view', type='json', auth='user')
def edit_report_view(self, report_name, report_views, record_id, operations=None):
# a report can be composed of multiple views (with t-call) ; we might
# thus need to apply operations on multiple views
# create groups of operations by view
groups = {}
ops = []
for op in operations:
ops += op.get('inheritance', [op])
for op in ops:
if str(op['view_id']) not in groups:
groups[str(op['view_id'])] = []
groups[str(op['view_id'])].append(op)
parser = etree.XMLParser(remove_blank_text=True)
for group_view_id in groups:
view = request.env['ir.ui.view'].browse(int(group_view_id))
if view.key in request.env['ir.ui.view'].TEMPLATE_VIEWS_BLACKLIST:
raise ValidationError(_("You cannot modify this view, it is part of the generic layout"))
arch = etree.fromstring(report_views[group_view_id]['studio_arch'], parser=parser)
for op in groups[group_view_id]:
if not op.get('type'):
# apply changes
content = etree.fromstring(op['content'], etree.HTMLParser())
for node in content[0]:
etree.SubElement(arch, 'xpath', {
'expr': op['xpath'],
'position': op['position'],
}).append(node)
else:
# call the right operation handler
op['position'] = op['type']
op['target'] = {
'xpath_info': [{
'tag': g.split('[')[0],
'indice': g.split('[')[1][:-1] if '[' in g else 1
} for g in op['xpath'].split('/')[1:]]
}
getattr(self, '_operation_%s' % (op['type']))(arch, op)
# Save or create changes into studio view, identifiable by xmlid
# Example for view id 42 of model crm.lead: web-studio_crm.lead-42
new_arch = etree.tostring(arch, encoding='unicode', pretty_print=True)
self._set_studio_view(view, new_arch)
# Normalize the view
# studio_view = self._get_studio_view(view)
# try:
# normalized_view = studio_view.normalize()
# self._set_studio_view(view, normalized_view)
# except ValidationError: # Element '<...>' cannot be located in parent view
# # If the studio view is not applicable after normalization, let's
# # just ignore the normalization step, it's better to have a studio
# # view that is not optimized than to prevent the user from making
# # the change he would like to make.
# self._set_studio_view(view, new_arch)
# in case of undo, there could be no operation anymore for a view so
# the view thus need to be reset
intact_view_ids = report_views.keys() - groups.keys()
for view_id in intact_view_ids:
intact_view = request.env['ir.ui.view'].browse(int(view_id))
studio_view = self._get_studio_view(intact_view)
if studio_view:
studio_view.arch_db = report_views[view_id]['studio_arch']
result = self.get_report_views(report_name, record_id)
return result
@http.route('/web_studio/edit_report_view_arch', type='json', auth='user')
def edit_report_view_arch(self, report_name, record_id, view_id, view_arch):
view = request.env['ir.ui.view'].browse(view_id)
view.write({'arch': view_arch})
# TODO: we might need to keep studio_arch as it was before the changes
result = self.get_report_views(report_name, record_id)
return result
@http.route('/web_studio/edit_report/test_load_assets', type='json', auth='user')
def edit_report_test_load_css(self):
Qweb = request.env['ir.qweb']
Attachment = request.env['ir.attachment']
html = Qweb._render('web.report_layout', values={
'studio': True,
})
root = etree.fromstring(html).getroottree()
links = [link.get('href') for link in root.findall("//link")]
if 'assets' in request.session.debug:
domain = []
for link in links:
if domain:
domain = ['|'] + domain
domain.append(('name', '=', link.replace('/web/assets/debug/', '')))
attachments = Attachment.search(domain)
else:
link_ids = [int(link.replace('/web/assets/', '').split('-', 1)[0]) for link in links]
attachments = Attachment.browse(link_ids)
css = {a.name: base64.b64decode(a.datas) for a in attachments}
return {
"css": css
}
def _test_report(self, report_name, record_id):
# render the report to catch a rendering error
try:
return request.env['ir.actions.report']._render_qweb_html(report_name, [record_id], {
'full_branding': True,
'studio': True,
})
except Exception as err:
# the report could not be rendered which probably means the last
# operation was incorrect
return [{
"error": err,
"message": str(err),
}]