# -*- coding: utf-8 -*-
import collections
+import copy
import cStringIO
import datetime
import hashlib
from openerp.http import request
from openerp.tools.safe_eval import safe_eval as eval
from openerp.osv import osv, orm, fields
-from openerp.tools import html_escape as escape
+from openerp.tools import html_escape as escape, which
from openerp.tools.translate import _
_logger = logging.getLogger(__name__)
e.qweb['cause'] = original
raise
+class FileSystemLoader(object):
+ def __init__(self, path):
+ # TODO: support multiple files #add_file() + add cache
+ self.path = path
+ self.doc = etree.parse(path).getroot()
+
+ def __iter__(self):
+ for node in self.doc:
+ name = node.get('t-name')
+ if name:
+ yield name
+
+ def __call__(self, name):
+ for node in self.doc:
+ if node.get('t-name') == name:
+ root = etree.Element('templates')
+ root.append(copy.deepcopy(node))
+ arch = etree.tostring(root, encoding='utf-8', xml_declaration=True)
+ return arch
+
class QWebContext(dict):
- def __init__(self, cr, uid, data, loader=None, templates=None, context=None):
+ def __init__(self, cr, uid, data, loader=None, context=None):
self.cr = cr
self.uid = uid
self.loader = loader
- self.templates = templates or {}
self.context = context
dic = dict(data)
super(QWebContext, self).__init__(dic)
"""
return QWebContext(self.cr, self.uid, dict.copy(self),
loader=self.loader,
- templates=self.templates,
context=self.context)
def __copy__(self):
def register_tag(self, tag, func):
self._render_tag[tag] = func
- def add_template(self, qwebcontext, name, node):
- """Add a parsed template in the context. Used to preprocess templates."""
- qwebcontext.templates[name] = node
-
- def load_document(self, document, res_id, qwebcontext):
- """
- Loads an XML document and installs any contained template in the engine
+ def get_template(self, name, qwebcontext):
+ origin_template = qwebcontext.get('__caller__') or qwebcontext['__stack__'][0]
+ try:
+ document = qwebcontext.loader(name)
+ except ValueError:
+ raise_qweb_exception(QWebTemplateNotFound, message="Loader could not find template %r" % name, template=origin_template)
- :type document: a parsed lxml.etree element, an unparsed XML document
- (as a string) or the path of an XML file to load
- """
- if not isinstance(document, basestring):
- # assume lxml.etree.Element
+ if hasattr(document, 'documentElement'):
dom = document
elif document.startswith("<?xml"):
dom = etree.fromstring(document)
else:
dom = etree.parse(document).getroot()
+ res_id = isinstance(name, (int, long)) and name or None
for node in dom:
- if node.get('t-name'):
- name = str(node.get("t-name"))
- self.add_template(qwebcontext, name, node)
- if res_id and node.tag == "t":
- self.add_template(qwebcontext, res_id, node)
- res_id = None
-
- def get_template(self, name, qwebcontext):
- """ Tries to fetch the template ``name``, either gets it from the
- context's template cache or loads one with the context's loader (if
- any).
-
- :raises QWebTemplateNotFound: if the template can not be found or loaded
- """
- origin_template = qwebcontext.get('__caller__') or qwebcontext['__stack__'][0]
- if qwebcontext.loader and name not in qwebcontext.templates:
- try:
- xml_doc = qwebcontext.loader(name)
- except ValueError:
- raise_qweb_exception(QWebTemplateNotFound, message="Loader could not find template %r" % name, template=origin_template)
- self.load_document(xml_doc, isinstance(name, (int, long)) and name or None, qwebcontext=qwebcontext)
-
- if name in qwebcontext.templates:
- return qwebcontext.templates[name]
+ if node.get('t-name') or (res_id and node.tag == "t"):
+ return node
raise QWebTemplateNotFound("Template %r not found" % name, template=origin_template)
generated_attributes = ""
t_render = None
template_attributes = {}
+
+ debugger = element.get('t-debug')
+ if debugger is not None:
+ if openerp.tools.config['dev_mode']:
+ __import__(debugger).set_trace() # pdb, ipdb, pudb, ...
+ else:
+ _logger.warning("@t-debug in template '%s' is only available in --dev mode" % qwebcontext['__template__'])
+
for (attribute_name, attribute_value) in element.attrib.iteritems():
attribute_name = str(attribute_name)
if attribute_name == "groups":
else:
generated_attributes += self.render_attribute(element, attribute_name, attribute_value, qwebcontext)
- if 'debug' in template_attributes:
- debugger = template_attributes.get('debug', 'pdb')
- __import__(debugger).set_trace() # pdb, ipdb, pudb, ...
if t_render:
result = self._render_tag[t_render](self, element, template_attributes, generated_attributes, qwebcontext)
else:
result = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
if element.tail:
- result += element.tail.encode('utf-8')
+ result += self.render_tail(element.tail, element, qwebcontext)
if isinstance(result, unicode):
return result.encode('utf-8')
if inner:
g_inner = inner.encode('utf-8') if isinstance(inner, unicode) else inner
else:
- g_inner = [] if element.text is None else [element.text.encode('utf-8')]
+ g_inner = [] if element.text is None else [self.render_text(element.text, element, qwebcontext)]
for current_node in element.iterchildren(tag=etree.Element):
try:
g_inner.append(self.render_node(current_node, qwebcontext))
def render_attribute(self, element, name, value, qwebcontext):
return ' %s="%s"' % (name, escape(value))
+ def render_text(self, text, element, qwebcontext):
+ return text.encode('utf-8')
+
+ def render_tail(self, tail, element, qwebcontext):
+ return tail.encode('utf-8')
+
# Attributes
def render_att_att(self, element, attribute_name, attribute_value, qwebcontext):
if attribute_name.startswith("t-attf-"):
val = {
'name': value.split("\n")[0],
- 'address': escape("\n".join(value.split("\n")[1:])),
+ 'address': escape("\n".join(value.split("\n")[1:])).strip(),
'phone': value_rec.phone,
'mobile': value_rec.mobile,
'fax': value_rec.fax,
pass
class AssetsBundle(object):
- # Sass installation:
- #
- # sudo gem install sass compass bootstrap-sass
- #
- # If the following error is encountered:
- # 'ERROR: Cannot load compass.'
- # Use this:
- # sudo gem install compass --pre
- cmd_sass = ['sass', '--stdin', '-t', 'compressed', '--unix-newlines', '--compass', '-r', 'bootstrap-sass']
rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
- rx_sass_import = re.compile("""(@import\s?['"]([^'"]+)['"])""")
+ rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
def __init__(self, xmlid, debug=False, cr=None, uid=None, context=None, registry=None):
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))
+ self.stylesheets.append(SassStylesheetAsset(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))
+ self.stylesheets.append(SassStylesheetAsset(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:
response = []
if debug:
if css and self.stylesheets:
- self.compile_sass()
+ 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:
return content
def css(self):
+ """Generate css content from given bundle"""
content = self.get_cache('css')
if content is None:
- self.compile_sass()
- content = '\n'.join(asset.minify() for asset in self.stylesheets)
+ content = self.preprocess_css()
if self.css_errors:
msg = '\n'.join(self.css_errors)
- content += self.css_message(msg.replace('\n', '\\A '))
+ content += self.css_message(msg)
# move up all @import rules to the top
matches = []
def set_cache(self, type, content):
ira = self.registry['ir.attachment']
- url_prefix = '/web/%s/%s/' % (type, self.xmlid)
- # Invalidate previous caches
- oids = ira.search(self.cr, openerp.SUPERUSER_ID, [('url', '=like', url_prefix + '%')], context=self.context)
- if oids:
- ira.unlink(self.cr, openerp.SUPERUSER_ID, oids, context=self.context)
- url = url_prefix + self.version
+ 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',
), context=self.context)
def css_message(self, message):
+ # '\A' == css content carriage return
+ message = message.replace('\n', '\\A ').replace('"', '\\"')
return """
body:before {
background: #ffc;
white-space: pre;
content: "%s";
}
- """ % message.replace('"', '\\"')
+ """ % message
- def compile_sass(self):
+ def preprocess_css(self):
"""
- Checks if the bundle contains any sass content, then compiles it to css.
- Css compilation is done at the bundle level and not in the assets
- because they are potentially interdependant.
+ Checks if the bundle contains any sass/less content, then compiles it to css.
+ Returns the bundle's flat css.
"""
- sass = [asset for asset in self.stylesheets if isinstance(asset, SassAsset)]
- if not sass:
- return
- source = '\n'.join([asset.get_source() for asset in sass])
-
- # move up all @import rules to the top and exclude file imports
+ for atype in (SassStylesheetAsset, 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_css(self, cmd, source):
+ """Sanitizes @import rules, remove duplicates @import rules, then compile"""
imports = []
- def push(matchobj):
+ def sanitize(matchobj):
ref = matchobj.group(2)
- line = '@import "%s"' % ref
+ 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_sass_import, push, source)
- imports.append(source)
- source = u'\n'.join(imports)
+ source = re.sub(self.rx_preprocess_imports, sanitize, source)
try:
- compiler = Popen(self.cmd_sass, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+ compiler = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
except Exception:
- msg = "Could not find 'sass' program needed to compile sass/scss files"
+ msg = "Could not execute command %r" % cmd[0]
_logger.error(msg)
self.css_errors.append(msg)
- return
+ return ''
result = compiler.communicate(input=source.encode('utf-8'))
if compiler.returncode:
- error = self.get_sass_error(''.join(result), source=source)
+ error = self.get_preprocessor_error(''.join(result), source=source)
_logger.warning(error)
self.css_errors.append(error)
- return
+ return ''
compiled = result[0].strip().decode('utf8')
- fragments = self.rx_css_split.split(compiled)[1:]
- while fragments:
- asset_id = fragments.pop(0)
- asset = next(asset for asset in sass if asset.id == asset_id)
- asset._content = fragments.pop(0)
-
- def get_sass_error(self, stderr, source=None):
- # TODO: try to find out which asset the error belongs to
+ 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):
+ if isinstance(asset, PreprocessedCSS):
error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
return error
@property
def content(self):
- if not self._content:
+ if self._content is None:
self._content = self.inline or self._fetch_content()
return self._content
content = super(StylesheetAsset, self)._fetch_content()
web_dir = os.path.dirname(self.url)
- content = self.rx_import.sub(
- r"""@import \1%s/""" % (web_dir,),
- content,
- )
+ if self.rx_import:
+ content = self.rx_import.sub(
+ r"""@import \1%s/""" % (web_dir,),
+ content,
+ )
- content = self.rx_url.sub(
- r"url(\1%s/" % (web_dir,),
- content,
- )
+ if self.rx_url:
+ content = self.rx_url.sub(
+ r"url(\1%s/" % (web_dir,),
+ content,
+ )
- # remove charset declarations, we only support utf-8
- content = self.rx_charset.sub('', 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 ''
- return content
def minify(self):
# remove existing sourcemaps, make no sense after re-mini
else:
return '<style type="text/css"%s>%s</style>' % (media, self.with_header())
-class SassAsset(StylesheetAsset):
+class PreprocessedCSS(StylesheetAsset):
html_url = '%s.css'
- rx_indent = re.compile(r'^( +|\t+)', re.M)
- indent = None
- reindent = ' '
+ rx_import = None
def minify(self):
return self.with_header()
if self.url:
ira = self.registry['ir.attachment']
url = self.html_url % self.url
- domain = [('type', '=', 'binary'), ('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': self.content}, context=self.context)
+ ira.write(self.cr, openerp.SUPERUSER_ID, ira_id, {'datas': datas}, context=self.context)
else:
ira.create(self.cr, openerp.SUPERUSER_ID, dict(
- datas=self.content.encode('utf8').encode('base64'),
+ datas=datas,
mimetype='text/css',
type='binary',
name=url,
url=url,
), context=self.context)
- return super(SassAsset, self).to_html()
+ return super(PreprocessedCSS, 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):
+ raise NotImplementedError
+
+class SassStylesheetAsset(PreprocessedCSS):
+ rx_indent = re.compile(r'^( +|\t+)', re.M)
+ indent = None
+ reindent = ' '
def get_source(self):
content = textwrap.dedent(self.inline or self._fetch_content())
def fix_indent(m):
+ # Indentation normalization
ind = m.group()
if self.indent is None:
self.indent = ind
pass
return "/*! %s */\n%s" % (self.id, content)
+ 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(PreprocessedCSS):
+ 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]
+
def rjsmin(script):
""" Minify js with a clever regex.
Taken from http://opensource.perlig.de/rjsmin