Files
odoo_source/odoo/addons/base/models/ir_qweb.py
T
8cc066173d [IMP] *: Improve assets management
This commit changes the way assets are declared in Odoo modules.

Before: assets were declared in template files. Template bundles were
generated from primary templates, so technically any qweb template could
have been called as an asset bundle, with the 't-call-assets' directive.

Being standard qweb templates, they had access to standard HTML tags
(script, link, with or without raw scripts or style definition), qweb
directives (t-call, t-raw, etc.) and could be inherited by other
templates.

Now: assets are defined in the module's manifest and generated by the
't-call-assets' directive.

More information on the new system can be found on the updated user
documentation (see the "JavaScript Reference" section).

Task: 2352566

Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Raphael Collet <rco@odoo.com>
Co-authored-by: Simon Genin <ges@odoo.com>
2021-03-31 13:57:17 +02:00

444 lines
19 KiB
Python

# -*- coding: utf-8 -*-
from __future__ import print_function
import ast
import copy
import json
import logging
from collections import OrderedDict
from time import time
from lxml import html
from lxml import etree
from odoo import api, models, tools
from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, _SAFE_OPCODES
from odoo.tools.misc import get_lang
from odoo.http import request
from odoo.modules.module import get_resource_path
from odoo.addons.base.models.qweb import QWeb, Contextifier
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS
_logger = logging.getLogger(__name__)
class IrQWeb(models.AbstractModel, QWeb):
""" Base QWeb rendering engine
* to customize ``t-field`` rendering, subclass ``ir.qweb.field`` and
create new models called :samp:`ir.qweb.field.{widget}`
Beware that if you need extensions or alterations which could be
incompatible with other subsystems, you should create a local object
inheriting from ``ir.qweb`` and customize that.
"""
_name = 'ir.qweb'
_description = 'Qweb'
@api.model
def _render(self, id_or_xml_id, values=None, **options):
""" render(id_or_xml_id, values, **options)
Render the template specified by the given name.
:param id_or_xml_id: name or etree (see get_template)
:param dict values: template values to be used for rendering
:param options: used to compile the template (the dict available for the rendering is frozen)
* ``load`` (function) overrides the load method
* ``profile`` (float) profile the rendering (use astor lib) (filter
profile line with time ms >= profile)
"""
context = dict(self.env.context, dev_mode='qweb' in tools.config['dev_mode'])
context.update(options)
result = super(IrQWeb, self)._render(id_or_xml_id, values=values, **context)
if b'data-pagebreak=' not in result:
return result
fragments = html.fragments_fromstring(result.decode('utf-8'))
for fragment in fragments:
for row in fragment.iterfind('.//tr[@data-pagebreak]'):
table = next(row.iterancestors('table'))
newtable = html.Element('table', attrib=dict(table.attrib))
thead = table.find('thead')
if thead:
newtable.append(copy.deepcopy(thead))
# TODO: copy caption & tfoot as well?
# TODO: move rows in a tbody if row.getparent() is one?
pos = row.get('data-pagebreak')
assert pos in ('before', 'after')
for sibling in row.getparent().iterchildren('tr'):
if sibling is row:
if pos == 'after':
newtable.append(sibling)
break
newtable.append(sibling)
table.addprevious(newtable)
table.addprevious(html.Element('div', attrib={
'style': 'page-break-after: always'
}))
return b''.join(html.tostring(f) for f in fragments)
def default_values(self):
""" attributes add to the values for each computed template
"""
default = super(IrQWeb, self).default_values()
default.update(request=request, cache_assets=round(time()/180), true=True, false=False) # true and false added for backward compatibility to remove after v10
return default
# assume cache will be invalidated by third party on write to ir.ui.view
def _get_template_cache_keys(self):
""" Return the list of context keys to use for caching ``_get_template``. """
return ['lang', 'inherit_branding', 'editable', 'translatable', 'edit_translations', 'website_id']
# apply ormcache_context decorator unless in dev mode...
@tools.conditional(
'xml' not in tools.config['dev_mode'],
tools.ormcache('id_or_xml_id', 'tuple(options.get(k) for k in self._get_template_cache_keys())'),
)
def compile(self, id_or_xml_id, options):
try:
id_or_xml_id = int(id_or_xml_id)
except:
pass
return super(IrQWeb, self).compile(id_or_xml_id, options=options)
def _load(self, name, options):
lang = options.get('lang', get_lang(self.env).code)
env = self.env
if lang != env.context.get('lang'):
env = env(context=dict(env.context, lang=lang))
view_id = self.env['ir.ui.view'].get_view_id(name)
template = env['ir.ui.view'].sudo()._read_template(view_id)
# QWeb's `_read_template` will check if one of the first children of
# what we send to it has a "t-name" attribute having `name` as value
# to consider it has found it. As it'll never be the case when working
# with view ids or children view or children primary views, force it here.
def is_child_view(view_name):
view_id = self.env['ir.ui.view'].get_view_id(view_name)
view = self.env['ir.ui.view'].sudo().browse(view_id)
return view.inherit_id is not None
if isinstance(name, int) or is_child_view(name):
view = etree.fromstring(template)
for node in view:
if node.get('t-name'):
node.set('t-name', str(name))
return view
else:
return template
# order
def _directives_eval_order(self):
directives = super(IrQWeb, self)._directives_eval_order()
directives.insert(directives.index('call'), 'lang')
directives.insert(directives.index('field'), 'call-assets')
return directives
# compile directives
def _compile_directive_lang(self, el, options):
lang = el.attrib.pop('t-lang', get_lang(self.env).code)
if el.get('t-call-options'):
el.set('t-call-options', el.get('t-call-options')[0:-1] + u', "lang": %s}' % lang)
else:
el.set('t-call-options', u'{"lang": %s}' % lang)
return self._compile_node(el, options)
def _compile_directive_call_assets(self, el, options):
""" This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
if len(el):
raise SyntaxError("t-call-assets cannot contain children nodes")
# nodes = self._get_asset_nodes(bundle, options, css=css, js=js, debug=values.get('debug'), async=async, values=values)
#
# for index, (tagName, t_attrs, content) in enumerate(nodes):
# if index:
# append('\n ')
# append('<')
# append(tagName)
#
# self._post_processing_att(tagName, t_attrs, options)
# for name, value in t_attrs.items():
# if value or isinstance(value, string_types)):
# append(u' ')
# append(name)
# append(u'="')
# append(escape(pycompat.to_text((value)))
# append(u'"')
#
# if not content and tagName in self._void_elements:
# append('/>')
# else:
# append('>')
# if content:
# append(content)
# append('</')
# append(tagName)
# append('>')
#
space = el.getprevious() is not None and el.getprevious().tail or el.getparent().text
sep = u'\n' + space.rsplit('\n').pop()
return [
ast.Assign(
targets=[ast.Name(id='nodes', ctx=ast.Store())],
value=ast.Call(
func=ast.Attribute(
value=ast.Name(id='self', ctx=ast.Load()),
attr='_get_asset_nodes',
ctx=ast.Load()
),
args=[
ast.Str(el.get('t-call-assets')),
ast.Name(id='options', ctx=ast.Load()),
],
keywords=[
ast.keyword('css', self._get_attr_bool(el.get('t-css', True))),
ast.keyword('js', self._get_attr_bool(el.get('t-js', True))),
ast.keyword('debug', ast.Call(
func=ast.Attribute(
value=ast.Name(id='values', ctx=ast.Load()),
attr='get',
ctx=ast.Load()
),
args=[ast.Str('debug')],
keywords=[], starargs=None, kwargs=None
)),
ast.keyword('async_load', self._get_attr_bool(el.get('async_load', False))),
ast.keyword('defer_load', self._get_attr_bool(el.get('defer_load', False))),
ast.keyword('lazy_load', self._get_attr_bool(el.get('lazy_load', False))),
ast.keyword('media', ast.Constant(el.get('media'))),
],
starargs=None, kwargs=None
)
),
ast.For(
target=ast.Tuple(elts=[
ast.Name(id='index', ctx=ast.Store()),
ast.Tuple(elts=[
ast.Name(id='tagName', ctx=ast.Store()),
ast.Name(id='t_attrs', ctx=ast.Store()),
ast.Name(id='content', ctx=ast.Store())
], ctx=ast.Store())
], ctx=ast.Store()),
iter=ast.Call(
func=ast.Name(id='enumerate', ctx=ast.Load()),
args=[ast.Name(id='nodes', ctx=ast.Load())],
keywords=[],
starargs=None, kwargs=None
),
body=[
ast.If(
test=ast.Name(id='index', ctx=ast.Load()),
body=[self._append(ast.Str(sep))],
orelse=[]
),
self._append(ast.Str(u'<')),
self._append(ast.Name(id='tagName', ctx=ast.Load())),
] + self._append_attributes() + [
ast.If(
test=ast.BoolOp(
op=ast.And(),
values=[
ast.UnaryOp(ast.Not(), ast.Name(id='content', ctx=ast.Load()), lineno=0, col_offset=0),
ast.Compare(
left=ast.Name(id='tagName', ctx=ast.Load()),
ops=[ast.In()],
comparators=[ast.Attribute(
value=ast.Name(id='self', ctx=ast.Load()),
attr='_void_elements',
ctx=ast.Load()
)]
),
]
),
body=[self._append(ast.Str(u'/>'))],
orelse=[
self._append(ast.Str(u'>')),
ast.If(
test=ast.Name(id='content', ctx=ast.Load()),
body=[self._append(ast.Name(id='content', ctx=ast.Load()))],
orelse=[]
),
self._append(ast.Str(u'</')),
self._append(ast.Name(id='tagName', ctx=ast.Load())),
self._append(ast.Str(u'>')),
]
)
],
orelse=[]
)
]
# method called by computing code
def get_asset_bundle(self, bundle_name, files, env=None, css=True, js=True):
return AssetsBundle(bundle_name, files, env=env, css=css, js=js)
def _get_asset_nodes(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
"""Generates asset nodes.
If debug=assets, the assets will be regenerated when a file which composes them has been modified.
Else, the assets will be generated only once and then stored in cache.
"""
if debug and 'assets' in debug:
return self._generate_asset_nodes(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
else:
return self._generate_asset_nodes_cache(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
@tools.conditional(
# in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear
# by restarting the server after updating the source code (or using the "Clear server cache" in debug tools)
'xml' not in tools.config['dev_mode'],
tools.ormcache_context('bundle', 'options.get("lang", "en_US")', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', keys=("website_id",)),
)
def _generate_asset_nodes_cache(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
return self._generate_asset_nodes(bundle, options, css, js, debug, async_load, defer_load, lazy_load, media)
def _generate_asset_nodes(self, bundle, options, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
nodeAttrs = None
if css and media:
nodeAttrs = {
'media': media,
}
files, remains = self._get_asset_content(bundle, options, nodeAttrs)
asset = self.get_asset_bundle(bundle, files, env=self.env, css=css, js=js)
remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] == 'script')]
return remains + asset.to_node(css=css, js=js, debug=debug, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load)
def _get_asset_link_urls(self, bundle, options):
asset_nodes = self._get_asset_nodes(bundle, options, js=False)
return [node[1]['href'] for node in asset_nodes if node[0] == 'link']
@tools.ormcache_context('bundle', 'options.get("lang", "en_US")', keys=("website_id",))
def _get_asset_content(self, bundle, options, nodeAttrs=None):
options = dict(options,
inherit_branding=False, inherit_branding_auto=False,
edit_translations=False, translatable=False,
rendering_bundle=True)
options['website_id'] = self.env.context.get('website_id')
asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, css=True, js=True)
files = []
remains = []
for path, *_ in asset_paths:
ext = path.split('.')[-1]
is_js = ext in SCRIPT_EXTENSIONS
is_css = ext in STYLE_EXTENSIONS
if not is_js and not is_css:
continue
mimetype = 'text/javascript' if is_js else 'text/%s' % ext
if can_aggregate(path):
segments = [segment for segment in path.split('/') if segment]
files.append({
'atype': mimetype,
'url': path,
'filename': get_resource_path(*segments) if segments else None,
'content': '',
'media': nodeAttrs and nodeAttrs.get('media'),
})
else:
if is_js:
tag = 'script'
attributes = {
"type": mimetype,
"src": path,
}
else:
tag = 'link'
attributes = {
"type": mimetype,
"rel": "stylesheet",
"href": path,
'media': nodeAttrs and nodeAttrs.get('media'),
}
remains.append((tag, attributes, ''))
return (files, remains)
def _get_field(self, record, field_name, expression, tagName, field_options, options, values):
field = record._fields[field_name]
# adds template compile options for rendering fields
field_options['template_options'] = options
# adds generic field options
field_options['tagName'] = tagName
field_options['expression'] = expression
field_options['type'] = field_options.get('widget', field.type)
inherit_branding = options.get('inherit_branding', options.get('inherit_branding_auto') and record.check_access_rights('write', False))
field_options['inherit_branding'] = inherit_branding
translate = options.get('edit_translations') and options.get('translatable') and field.translate
field_options['translate'] = translate
# field converter
model = 'ir.qweb.field.' + field_options['type']
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
# get content
content = converter.record_to_html(record, field_name, field_options)
attributes = converter.attributes(record, field_name, field_options, values)
return (attributes, content, inherit_branding or translate)
def _get_widget(self, value, expression, tagName, field_options, options, values):
# adds template compile options for rendering fields
field_options['template_options'] = options
field_options['type'] = field_options['widget']
field_options['tagName'] = tagName
field_options['expression'] = expression
# field converter
model = 'ir.qweb.field.' + field_options['type']
converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
# get content
content = converter.value_to_html(value, field_options)
attributes = OrderedDict()
attributes['data-oe-type'] = field_options['type']
attributes['data-oe-expression'] = field_options['expression']
return (attributes, content, None)
# compile expression add safe_eval
def _compile_expr(self, expr):
""" Compiles a purported Python expression to ast, verifies that it's safe
(according to safe_eval's semantics) and alter its variable references to
access values data instead
"""
# string must be stripped otherwise whitespace before the start for
# formatting purpose are going to break parse/compile
st = ast.parse(expr.strip(), mode='eval')
assert_valid_codeobj(
_SAFE_OPCODES,
compile(st, '<>', 'eval'), # could be expr, but eval *should* be fine
expr
)
# ast.Expression().body -> expr
return Contextifier(_BUILTINS).visit(st).body
def _get_attr_bool(self, attr, default=False):
if attr:
if attr is True:
return ast.Constant(True)
attr = attr.lower()
if attr in ('false', '0'):
return ast.Constant(False)
elif attr in ('true', '1'):
return ast.Constant(True)
return ast.Constant(attr if attr is False else bool(default))