345 lines
14 KiB
Python
345 lines
14 KiB
Python
import os
|
|
import re
|
|
import logging
|
|
from lxml import etree, html
|
|
from subprocess import Popen, PIPE
|
|
|
|
import openerp
|
|
from openerp.osv import orm
|
|
from openerp.tools import which
|
|
from openerp.addons.base.ir.ir_qweb import AssetError, JavascriptAsset, QWebException
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class QWeb_less(orm.AbstractModel):
|
|
_name = "website.qweb"
|
|
_inherit = "website.qweb"
|
|
|
|
def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
|
|
""" This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
|
|
if len(element):
|
|
# An asset bundle is rendered in two differents contexts (when genereting html and
|
|
# when generating the bundle itself) so they must be qwebcontext free
|
|
# even '0' variable is forbidden
|
|
template = qwebcontext.get('__template__')
|
|
raise QWebException("t-call-assets cannot contain children nodes", template=template)
|
|
xmlid = template_attributes['call-assets']
|
|
cr, uid, context = [getattr(qwebcontext, attr) for attr in ('cr', 'uid', 'context')]
|
|
bundle = AssetsBundle(xmlid, cr=cr, uid=uid, context=context, registry=self.pool)
|
|
css = self.get_attr_bool(template_attributes.get('css'), default=True)
|
|
js = self.get_attr_bool(template_attributes.get('js'), default=True)
|
|
return bundle.to_html(css=css, js=js, debug=bool(qwebcontext.get('debug')))
|
|
|
|
def render_tag_snippet(self, element, template_attributes, generated_attributes, qwebcontext):
|
|
d = qwebcontext.copy()
|
|
d[0] = self.render_element(element, template_attributes, generated_attributes, d)
|
|
cr = d.get('request') and d['request'].cr or None
|
|
uid = d.get('request') and d['request'].uid or None
|
|
|
|
template = self.eval_format(template_attributes["snippet"], d)
|
|
document = self.render(cr, uid, template, d)
|
|
node = etree.fromstring(document)
|
|
|
|
id = int(node.get('data-oe-id', self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, template, raise_if_not_found=True)))
|
|
node.set('data-oe-name', self.pool['ir.ui.view'].browse(cr, uid, id, context=qwebcontext.context).name)
|
|
node.set('data-oe-type', "snippet")
|
|
node.set('data-oe-thumbnail', template_attributes.get('thumbnail', "oe-thumbnail"))
|
|
|
|
return html.tostring(node, encoding='UTF-8')
|
|
|
|
|
|
class AssetsBundle(openerp.addons.base.ir.ir_qweb.AssetsBundle):
|
|
rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
|
|
|
|
def parse(self):
|
|
fragments = html.fragments_fromstring(self.html)
|
|
for el in fragments:
|
|
if isinstance(el, basestring):
|
|
self.remains.append(el)
|
|
elif isinstance(el, html.HtmlElement):
|
|
src = el.get('src', '')
|
|
href = el.get('href', '')
|
|
atype = el.get('type')
|
|
media = el.get('media')
|
|
if el.tag == 'style':
|
|
if atype == 'text/sass' or src.endswith('.sass'):
|
|
self.stylesheets.append(SassAsset(self, inline=el.text, media=media))
|
|
elif atype == 'text/less' or src.endswith('.less'):
|
|
self.stylesheets.append(LessStylesheetAsset(self, inline=el.text, media=media))
|
|
else:
|
|
self.stylesheets.append(StylesheetAsset(self, inline=el.text, media=media))
|
|
elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href):
|
|
if href.endswith('.sass') or atype == 'text/sass':
|
|
self.stylesheets.append(SassAsset(self, url=href, media=media))
|
|
elif href.endswith('.less') or atype == 'text/less':
|
|
self.stylesheets.append(LessStylesheetAsset(self, url=href, media=media))
|
|
else:
|
|
self.stylesheets.append(StylesheetAsset(self, url=href, media=media))
|
|
elif el.tag == 'script' and not src:
|
|
self.javascripts.append(JavascriptAsset(self, inline=el.text))
|
|
elif el.tag == 'script' and self.can_aggregate(src):
|
|
self.javascripts.append(JavascriptAsset(self, url=src))
|
|
else:
|
|
self.remains.append(html.tostring(el))
|
|
else:
|
|
try:
|
|
self.remains.append(html.tostring(el))
|
|
except Exception:
|
|
# notYETimplementederror
|
|
raise NotImplementedError
|
|
|
|
def to_html(self, sep=None, css=True, js=True, debug=False):
|
|
if sep is None:
|
|
sep = '\n '
|
|
response = []
|
|
if debug:
|
|
if css and self.stylesheets:
|
|
self.preprocess_css()
|
|
if self.css_errors:
|
|
msg = '\n'.join(self.css_errors)
|
|
self.stylesheets.append(StylesheetAsset(self, inline=self.css_message(msg)))
|
|
for style in self.stylesheets:
|
|
response.append(style.to_html())
|
|
if js:
|
|
for jscript in self.javascripts:
|
|
response.append(jscript.to_html())
|
|
else:
|
|
url_for = self.context.get('url_for', lambda url: url)
|
|
if css and self.stylesheets:
|
|
href = '/web/css/%s/%s' % (self.xmlid, self.version)
|
|
response.append('<link href="%s" rel="stylesheet"/>' % url_for(href))
|
|
if js:
|
|
src = '/web/js/%s/%s' % (self.xmlid, self.version)
|
|
response.append('<script type="text/javascript" src="%s"></script>' % url_for(src))
|
|
response.extend(self.remains)
|
|
return sep + sep.join(response)
|
|
|
|
def css(self):
|
|
content = self.get_cache('css')
|
|
if content is None:
|
|
content = self.preprocess_css()
|
|
|
|
if self.css_errors:
|
|
msg = '\n'.join(self.css_errors)
|
|
content += self.css_message(msg)
|
|
|
|
# move up all @import rules to the top
|
|
matches = []
|
|
|
|
def push(matchobj):
|
|
matches.append(matchobj.group(0))
|
|
return ''
|
|
|
|
content = re.sub(self.rx_css_import, push, content)
|
|
|
|
matches.append(content)
|
|
content = u'\n'.join(matches)
|
|
if self.css_errors:
|
|
return content
|
|
self.set_cache('css', content)
|
|
|
|
return content
|
|
|
|
def set_cache(self, type, content):
|
|
ira = self.registry['ir.attachment']
|
|
ira.invalidate_bundle(self.cr, openerp.SUPERUSER_ID, type=type, xmlid=self.xmlid)
|
|
url = '/web/%s/%s/%s' % (type, self.xmlid, self.version)
|
|
ira.create(self.cr, openerp.SUPERUSER_ID, dict(
|
|
datas=content.encode('utf8').encode('base64'),
|
|
type='binary',
|
|
name=url,
|
|
url=url,
|
|
), context=self.context)
|
|
|
|
def css_message(self, message):
|
|
# '\A' == css content carriage return
|
|
message = message.replace('\n', '\\A ').replace('"', '\\"')
|
|
return """
|
|
body:before {
|
|
background: #ffc;
|
|
width: 100%%;
|
|
font-size: 14px;
|
|
font-family: monospace;
|
|
white-space: pre;
|
|
content: "%s";
|
|
}
|
|
""" % message
|
|
|
|
def preprocess_css(self):
|
|
"""
|
|
Checks if the bundle contains any sass/less content, then compiles it to css.
|
|
Returns the bundle's flat css.
|
|
"""
|
|
for atype in (SassAsset, LessStylesheetAsset):
|
|
assets = [asset for asset in self.stylesheets if isinstance(asset, atype)]
|
|
if assets:
|
|
cmd = assets[0].get_command()
|
|
source = '\n'.join([asset.get_source() for asset in assets])
|
|
compiled = self.compile_css(cmd, source)
|
|
|
|
fragments = self.rx_css_split.split(compiled)
|
|
at_rules = fragments.pop(0)
|
|
if at_rules:
|
|
# Sass and less moves @at-rules to the top in order to stay css 2.1 compatible
|
|
self.stylesheets.insert(0, StylesheetAsset(self, inline=at_rules))
|
|
while fragments:
|
|
asset_id = fragments.pop(0)
|
|
asset = next(asset for asset in self.stylesheets if asset.id == asset_id)
|
|
asset._content = fragments.pop(0)
|
|
|
|
return '\n'.join(asset.minify() for asset in self.stylesheets)
|
|
|
|
def compile_sass(self):
|
|
self.preprocess_css()
|
|
|
|
def compile_css(self, cmd, source):
|
|
"""Sanitizes @import rules, remove duplicates @import rules, then compile"""
|
|
imports = []
|
|
|
|
def sanitize(matchobj):
|
|
ref = matchobj.group(2)
|
|
line = '@import "%s"%s' % (ref, matchobj.group(3))
|
|
if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
|
|
imports.append(line)
|
|
return line
|
|
msg = "Local import '%s' is forbidden for security reasons." % ref
|
|
_logger.warning(msg)
|
|
self.css_errors.append(msg)
|
|
return ''
|
|
source = re.sub(self.rx_preprocess_imports, sanitize, source)
|
|
|
|
try:
|
|
compiler = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
|
|
except Exception:
|
|
msg = "Could not execute command %r" % cmd[0]
|
|
_logger.error(msg)
|
|
self.css_errors.append(msg)
|
|
return ''
|
|
result = compiler.communicate(input=source.encode('utf-8'))
|
|
if compiler.returncode:
|
|
error = self.get_preprocessor_error(''.join(result), source=source)
|
|
_logger.warning(error)
|
|
self.css_errors.append(error)
|
|
return ''
|
|
compiled = result[0].strip().decode('utf8')
|
|
return compiled
|
|
|
|
def get_preprocessor_error(self, stderr, source=None):
|
|
"""Improve and remove sensitive information from sass/less compilator error messages"""
|
|
error = stderr.split('Load paths')[0].replace(' Use --trace for backtrace.', '')
|
|
if 'Cannot load compass' in error:
|
|
error += "Maybe you should install the compass gem using this extra argument:\n\n" \
|
|
" $ sudo gem install compass --pre\n"
|
|
error += "This error occured while compiling the bundle '%s' containing:" % self.xmlid
|
|
for asset in self.stylesheets:
|
|
if isinstance(asset, (SassAsset, LessStylesheetAsset)):
|
|
error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
|
|
return error
|
|
|
|
|
|
class StylesheetAsset(openerp.addons.base.ir.ir_qweb.StylesheetAsset):
|
|
|
|
@property
|
|
def content(self):
|
|
if self._content is None:
|
|
self._content = self.inline or self._fetch_content()
|
|
return self._content
|
|
|
|
def _fetch_content(self):
|
|
try:
|
|
content = openerp.addons.base.ir.ir_qweb.WebAsset._fetch_content(self)
|
|
web_dir = os.path.dirname(self.url)
|
|
|
|
if self.rx_import:
|
|
content = self.rx_import.sub(
|
|
r"""@import \1%s/""" % (web_dir,),
|
|
content,
|
|
)
|
|
|
|
if self.rx_url:
|
|
content = self.rx_url.sub(
|
|
r"url(\1%s/" % (web_dir,),
|
|
content,
|
|
)
|
|
|
|
if self.rx_charset:
|
|
# remove charset declarations, we only support utf-8
|
|
content = self.rx_charset.sub('', content)
|
|
|
|
return content
|
|
except AssetError, e:
|
|
self.bundle.css_errors.append(e.message)
|
|
return ''
|
|
|
|
|
|
class SassAsset(StylesheetAsset, openerp.addons.base.ir.ir_qweb.SassAsset):
|
|
|
|
def to_html(self):
|
|
if self.url:
|
|
ira = self.registry['ir.attachment']
|
|
url = self.html_url % self.url
|
|
domain = [('type', '=', 'binary'), ('url', '=', url)]
|
|
ira_id = ira.search(self.cr, openerp.SUPERUSER_ID, domain, context=self.context)
|
|
datas = self.content.encode('utf8').encode('base64')
|
|
if ira_id:
|
|
# TODO: update only if needed
|
|
ira.write(self.cr, openerp.SUPERUSER_ID, ira_id, {'datas': datas}, context=self.context)
|
|
else:
|
|
ira.create(self.cr, openerp.SUPERUSER_ID, dict(
|
|
datas=datas,
|
|
mimetype='text/css',
|
|
type='binary',
|
|
name=url,
|
|
url=url,
|
|
), context=self.context)
|
|
return super(SassAsset, self).to_html()
|
|
|
|
def get_command(self):
|
|
defpath = os.environ.get('PATH', os.defpath).split(os.pathsep)
|
|
sass = which('sass', path=os.pathsep.join(defpath))
|
|
return [sass, '--stdin', '-t', 'compressed', '--unix-newlines', '--compass',
|
|
'-r', 'bootstrap-sass']
|
|
|
|
|
|
class LessStylesheetAsset(StylesheetAsset):
|
|
html_url = '%s.css'
|
|
rx_import = None
|
|
|
|
def minify(self):
|
|
return self.with_header()
|
|
|
|
def to_html(self):
|
|
if self.url:
|
|
ira = self.registry['ir.attachment']
|
|
url = self.html_url % self.url
|
|
domain = [('type', '=', 'binary'), ('url', '=', url)]
|
|
ira_id = ira.search(self.cr, openerp.SUPERUSER_ID, domain, context=self.context)
|
|
datas = self.content.encode('utf8').encode('base64')
|
|
if ira_id:
|
|
# TODO: update only if needed
|
|
ira.write(self.cr, openerp.SUPERUSER_ID, ira_id, {'datas': datas}, context=self.context)
|
|
else:
|
|
ira.create(self.cr, openerp.SUPERUSER_ID, dict(
|
|
datas=datas,
|
|
mimetype='text/css',
|
|
type='binary',
|
|
name=url,
|
|
url=url,
|
|
), context=self.context)
|
|
return super(LessStylesheetAsset, self).to_html()
|
|
|
|
def get_source(self):
|
|
content = self.inline or self._fetch_content()
|
|
return "/*! %s */\n%s" % (self.id, content)
|
|
|
|
def get_command(self):
|
|
defpath = os.environ.get('PATH', os.defpath).split(os.pathsep)
|
|
if os.name == 'nt':
|
|
lessc = which('lessc.cmd', path=os.pathsep.join(defpath))
|
|
else:
|
|
lessc = which('lessc', path=os.pathsep.join(defpath))
|
|
webpath = openerp.http.addons_manifest['web']['addons_path']
|
|
lesspath = os.path.join(webpath, 'web', 'static', 'lib', 'bootstrap', 'less')
|
|
return [lessc, '-', '--clean-css', '--no-js', '--no-color', '--include-path=%s' % lesspath]
|