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('' % url_for(href))
if js:
src = '/web/js/%s/%s' % (self.xmlid, self.version)
response.append('' % 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 '')
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]