Files
2024-04-28 11:43:49 +08:00

530 lines
20 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from lxml import etree, html
from psycopg2 import OperationalError
from odoo import http, _, Command, models
from odoo.http import request, serialize_exception
from odoo.addons.web_studio.controllers import main
from odoo.tools.safe_eval import safe_eval
def api_tree_or_string(func):
def from_tree_or_string(tree_or_string, *args, **kwargs):
is_string = isinstance(tree_or_string, str)
tree = html.fromstring(tree_or_string) if is_string else tree_or_string
res = func(tree, *args, **kwargs)
return html.tostring(res) if is_string else tree
return from_tree_or_string
def _transform_table(table):
for el in table.xpath(".|.//thead|.//tbody|.//tfoot|.//th|.//tr|.//td"):
tag = el.tag
el.set("oe-origin-tag", tag)
el.tag = "div"
el.set("oe-origin-style", el.attrib.pop("style", ""))
@api_tree_or_string
def _html_to_client_compliant(tree):
for table in tree.xpath("//table[descendant-or-self::t[not(ancestor::td)]]"):
_transform_table(table)
return tree
@api_tree_or_string
def _cleanup_from_client(tree):
tree = _to_qweb(tree)
for el in tree.xpath("//*[@oe-context]"):
el.attrib.pop("oe-context")
for el in tree.xpath("//*[@oe-expression-readable]"):
el.attrib.pop("oe-expression-readable")
for el in tree.xpath("//img[@t-att-src]"):
el.attrib.pop("src")
return tree
@api_tree_or_string
def _to_qweb(tree):
for el in tree.xpath("//*[@*[starts-with(name(), 'oe-origin-')]]"):
for att in el.attrib:
if not att.startswith("oe-origin-"):
continue
origin_name = att[10:]
att_value = el.attrib.pop(att)
if origin_name == "tag":
el.tag = att_value
else:
if att_value:
el.set(origin_name, att_value)
elif origin_name in el.attrib:
el.attrib.pop(origin_name)
return tree
def human_readable_dotted_expr(env, model, chain):
chain.reverse()
human_readable = []
while chain and model is not None:
fname = chain.pop()
field = model._fields[fname] if fname in model._fields else None
if field is not None:
human_readable.append(field.get_description(env, ["string"])["string"])
model = env[field.comodel_name] if field.comodel_name else None
else:
model = None
human_readable.append(fname.split("(")[0])
human_readable.extend(reversed(chain))
return human_readable
def parse_simple_dotted_expr(expr):
parsed = []
fn_level = 0
single_expr = []
for char in expr:
if char == "." and not fn_level:
parsed.append("".join(single_expr))
single_expr = []
continue
elif char == '(':
fn_level += 1
elif char == ')':
fn_level -= 1
single_expr.append(char)
parsed.append("".join(single_expr))
return parsed
def expr_to_simple_chain(expr, env, main_model, qcontext):
chain = parse_simple_dotted_expr(expr)
if not chain:
return ""
model = qcontext[chain[0]] if chain[0] in qcontext else None
if model is not None and hasattr(model, "_name") and model._name in env:
model_description = None
if model._name != main_model:
model_description = env["ir.model"]._get(model._name).name
new_chain = [model_description] if model_description else []
new_chain.extend(human_readable_dotted_expr(env, model, chain[1:]))
return " > ".join(new_chain) if new_chain else ""
else:
return ""
@api_tree_or_string
def _guess_qweb_variables(tree, report, qcontext):
qcontext = dict(qcontext)
keys_info = {}
env = report.env
qcontext["company"] = env.company
IrQweb = env["ir.qweb"]
def qweb_like_eval(expr, values, is_format=False):
qcontext = {"values": values}
if not is_format:
compiled = IrQweb._compile_expr(expr)
else:
qcontext["self"] = IrQweb
compiled = IrQweb._compile_format(expr)
return safe_eval(compiled, qcontext)
def qweb_like_string_eval(expr, qcontext, is_format=False):
try:
return qweb_like_eval(expr, qcontext, is_format) or ""
except OperationalError:
raise
except Exception: # pylint: disable=W0718,W0703
pass
return ""
def apply_oe_context(node, qcontext, keys_info):
oe_context = {}
for k, v in qcontext.items():
try:
if v._name in env:
oe_context[k] = {
"model": v._name,
"name": env["ir.model"]._get(v._name).name,
"in_foreach": keys_info.get(k, {}).get("in_foreach", False)
}
# Don't even warn: we just want models in the context
# pylint: disable=W0702
except:
continue
node.set("oe-context", json.dumps(oe_context))
def recursive(node, qcontext, keys_info):
if "t-foreach" in node.attrib:
expr = node.get("t-foreach")
# compile
new_var = node.get("t-as")
qcontext = dict(qcontext)
keys_info = dict(keys_info)
try:
qcontext[new_var] = qweb_like_eval(expr, qcontext)
keys_info[new_var] = {"in_foreach": True, "type": "python"}
except OperationalError:
raise
except Exception: # pylint: disable=W0718,W0703
pass
apply_oe_context(node, qcontext, keys_info)
if "t-set" in node.attrib and "t-value" in node.attrib:
new_var = node.get("t-set")
expr = node.get("t-value")
try:
evalled = qweb_like_eval(expr, qcontext)
if new_var not in qcontext or not isinstance(evalled, type(qcontext[new_var])):
keys_info[new_var] = {"type": "python"}
qcontext[new_var] = evalled
except OperationalError:
raise
except Exception: # pylint: disable=W0718,W0703
pass
apply_oe_context(node, qcontext, keys_info)
if "t-attf-class" in node.attrib or "t-att-class" in node.attrib:
klass = node.get("class", "")
node.set("oe-origin-class", klass)
new_class = ""
if "t-att-class" in node.attrib:
expr = node.get("t-att-class")
new_class += qweb_like_string_eval(expr, qcontext)
if "t-attf-class" in node.attrib:
expr = node.get("t-attf-class")
new_class += qweb_like_string_eval(expr, qcontext, is_format=True)
node.set("class", new_class)
if "t-field" in node.attrib:
expr = node.get("t-field")
human_readable = expr_to_simple_chain(expr, env, report.model, qcontext) or "Field"
node.set("oe-expression-readable", human_readable)
tout = [att for att in ("t-out", "t-esc") if att in node.attrib]
if tout and not node.get(tout[0]) == "0":
expr = node.get(tout[0])
human_readable = expr_to_simple_chain(expr, env, report.model, qcontext) or "Expression"
node.set("oe-expression-readable", human_readable)
if node.tag == "img" and ("t-att-src" in node.attrib):
src = node.get("t-att-src")
is_company_logo = src == "image_data_uri(company.logo)"
placeholder = f'/logo.png?company={env.company.id}' if is_company_logo else'/web/static/img/placeholder.png'
src = qweb_like_string_eval(src, qcontext) or placeholder
node.set("src", src)
if node.get("id") == "wrapwrap":
apply_oe_context(node, qcontext, keys_info)
for child in node:
recursive(child, qcontext, keys_info)
recursive(tree, qcontext, keys_info)
return tree
VIEW_BACKUP_KEY = "web_studio.__backup__._{view.id}_._{view.key}_"
def get_report_view_copy(view):
key = VIEW_BACKUP_KEY.format(view=view)
return view.with_context(active_test=False).search([("key", "=", key)], limit=1)
def _copy_report_view(view):
copy = get_report_view_copy(view)
if not copy:
key = VIEW_BACKUP_KEY.format(view=view)
copy = view.copy({
"name": f"web_studio_backup__{view.name}",
"inherit_id": False,
"mode": "primary",
"key": key,
"active": False,
})
return copy
class WebStudioReportController(main.WebStudioController):
@http.route('/web_studio/create_new_report', type='json', auth='user')
def create_new_report(self, model_name, layout, context=None):
if context:
request.update_context(**context)
if layout == 'web.basic_layout':
arch_document = etree.fromstring("""
<t t-name="studio_report_document">
<div class="page"><br/></div>
</t>
""")
else:
arch_document = etree.fromstring("""
<t t-name="studio_report_document">
<t t-call="%(layout)s">
<div class="page"><br/></div>
</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,
'display_name': report.display_name,
'report_name': report.name,
}
@http.route('/web_studio/print_report', type='json', auth='user')
def print_report(self, report_id, record_id):
report = request.env['ir.actions.report'].with_context(report_pdf_no_attachment=True, discard_logo_check=True)._get_report(report_id)
return report.report_action(record_id)
@http.route('/web_studio/load_report_editor', type='json', auth='user')
def load_report_editor(self, report_id, fields, context=None):
if context:
request.update_context(**context)
report = request.env['ir.actions.report'].browse(report_id)
report_data = report.read(fields)
paperformat = report._read_paper_format_measures()
qweb_error = None
try:
report_qweb = self._get_report_qweb(report)
except ValueError as e:
if (hasattr(e, "context") and isinstance(e.context.get("view"), models.BaseModel)):
# This is coming from _raise_view_error, don't crash
report_qweb = None
qweb_error = serialize_exception(e)
else:
raise e
return {
"report_data": report_data and report_data[0],
"paperformat": paperformat,
"report_qweb": report_qweb,
"qweb_error": qweb_error,
}
@http.route('/web_studio/get_report_html', type='json', auth='user')
def get_report_html(self, report_id, record_id, context=None):
if context:
request.update_context(**context)
report = request.env['ir.actions.report'].browse(report_id)
report_html = self._render_report(report, record_id)
return report_html and report_html[0]
@http.route('/web_studio/get_report_qweb', type='json', auth='user')
def get_report_qweb(self, report_id, context=None):
if context:
request.update_context(**context)
report = request.env['ir.actions.report'].browse(report_id)
return self._get_report_qweb(report)
def _get_report_qweb(self, report):
loaded = {}
report = report.with_context(studio=True)
report_name = report.report_name
IrQweb = request.env["ir.qweb"].with_context(studio=True, inherit_branding=True, lang=None)
def inline_t_call(tree, variables):
if variables:
touts = tree.xpath("//t[@t-out='0']")
for node in touts:
val = variables.get("__zero__")
if not val:
continue
subtree = etree.fromstring(val)
for child in subtree:
node.append(child)
node.attrib.pop("t-out")
if not variables:
variables = dict()
for node in tree.xpath("//*[@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
_vars = dict(variables)
z = etree.Element("t", {'process_zero': "1"})
for child in node:
if not child.get("t-set"):
z.append(child)
if len(z) > 0:
_vars["__zero__"] = etree.tostring(z)
sub_element = load_arch(tcall, _vars)
node.append(sub_element)
def load_arch(view_name, variables=None):
if not variables:
variables = dict()
if view_name in loaded:
tree = etree.fromstring(loaded[view_name])
elif view_name == "web.external_layout":
external_layout = "web.external_layout_standard"
if request.env.company.external_report_layout_id:
external_layout = request.env.company.external_report_layout_id.sudo().key
tree = load_arch(external_layout, variables)
else:
tree = IrQweb._get_template(view_name)[0]
loaded[view_name] = etree.tostring(tree)
inline_t_call(tree, variables)
return tree
main_qweb = _html_to_client_compliant(load_arch(report_name))
render_context = report._get_rendering_context(report, [0], {"studio": True})
render_context['report_type'] = "pdf"
main_qweb = _guess_qweb_variables(main_qweb, report, render_context)
html_container = request.env["ir.ui.view"]._render_template("web.html_container", {"studio": True})
html_container = html.fromstring(html_container)
main_qweb.xpath("//*[@id='wrapwrap']")[0]
wrap = html_container.xpath("//*[@id='wrapwrap']")[0]
wrap.getparent().replace(wrap, main_qweb.xpath("//*[@id='wrapwrap']")[0])
return html.tostring(html_container)
def _render_report(self, report, record_id):
return request.env['ir.actions.report'].with_context(studio=True)._render_qweb_html(report, [record_id] if record_id else [], {"studio": True})
@http.route("/web_studio/save_report", type="json", auth="user")
def save_report(self, report_id, report_changes=None, html_parts=None, xml_verbatim=None, record_id=None, context=None):
if context:
request.update_context(**context)
report_data = None
paperformat = None
report = request.env["ir.actions.report"].browse(report_id)
if report_changes:
to_write = dict(report_changes)
if to_write["display_in_print_menu"] is True:
to_write["binding_model_id"] = to_write["binding_model_id"][0] if to_write["binding_model_id"] else report.model_id
else:
to_write["binding_model_id"] = False
del to_write["display_in_print_menu"]
if to_write["attachment_use"]:
to_write["attachment"] = f"'{report.name}'"
else:
to_write["attachment"] = False
to_write["paperformat_id"] = to_write["paperformat_id"][0] if to_write["paperformat_id"] else False
to_write["groups_id"] = [Command.clear()] + [Command.link(_id) for _id in to_write["groups_id"]]
report.write(to_write)
report_data = report.read(to_write.keys())
paperformat = report._read_paper_format_measures()
IrView = request.env["ir.ui.view"].with_context(studio=True, no_cow=True, lang=None)
xml_ids = request.env["ir.model.data"]
if html_parts:
for view_id, data in html_parts.items():
view = IrView.browse(int(view_id))
_copy_report_view(view)
for xpath, escaped_html in data.items():
if xpath == "entire_view":
xpath = "."
view.save(_cleanup_from_client(escaped_html), xpath)
xml_ids = xml_ids | view.model_data_id
if xml_verbatim:
for view_id, arch in xml_verbatim.items():
view = IrView.browse(int(view_id))
_copy_report_view(view)
view.write({"arch": arch})
xml_ids = xml_ids | view.model_data_id
if report_changes or html_parts or xml_verbatim:
xml_ids |= request.env['ir.model.data'].sudo().search(["&", ("model", "=", report._name), ("res_id", "=", report.id)])
if xml_ids:
xml_ids.write({"noupdate": True})
# We always try to render the full report here because in case of failure, we need
# the transaction to rollback
report_html = self._render_report(report, record_id)
report_qweb = self._get_report_qweb(report)
return {
"report_qweb": report_qweb,
"report_html": report_html and report_html[0],
"paperformat": paperformat,
"report_data": report_data and report_data[0],
}
@http.route("/web_studio/reset_report_archs", type="json", auth="user")
def reset_report_archs(self, report_id, include_web_layout=True):
report = request.env["ir.actions.report"].browse(report_id)
views = request.env["ir.ui.view"].with_context(no_primary_children=True, __views_get_original_hierarchy=[], no_cow=True).get_related_views(report.report_name, bundles=False)
if not include_web_layout:
views = views.filtered(lambda v: not v.key.startswith("web.") or "layout" not in v.key)
views.reset_arch(mode="hard")
return True