[MERGE] forward port of branch 8.0 up to 591e329
[odoo/odoo.git] / openerp / addons / base / ir / ir_qweb.py
index bb422d5..26045a3 100644 (file)
@@ -1,33 +1,36 @@
 # -*- coding: utf-8 -*-
 import collections
+import copy
 import cStringIO
 import datetime
 import hashlib
 import json
+import itertools
 import logging
 import math
 import os
 import re
 import sys
-import xml  # FIXME use lxml and etree
-import itertools
-import lxml.html
+import textwrap
+import uuid
+from subprocess import Popen, PIPE
 from urlparse import urlparse
 
 import babel
 import babel.dates
 import werkzeug
+from lxml import etree, html
 from PIL import Image
 
 import openerp.http
 import openerp.tools
-import openerp.tools.func
+from openerp.tools.func import lazy_property
 import openerp.tools.lru
 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, which
 from openerp.tools.translate import _
-from openerp import SUPERUSER_ID
 
 _logger = logging.getLogger(__name__)
 
@@ -38,6 +41,10 @@ class QWebException(Exception):
     def __init__(self, message, **kw):
         Exception.__init__(self, message)
         self.qweb = dict(kw)
+    def pretty_xml(self):
+        if 'node' not in self.qweb:
+            return ''
+        return etree.tostring(self.qweb['node'], pretty_print=True)
 
 class QWebTemplateNotFound(QWebException):
     pass
@@ -55,12 +62,31 @@ def raise_qweb_exception(etype=None, **kw):
         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)
@@ -74,37 +100,23 @@ class QWebContext(dict):
         return eval(expr, None, locals_dict, nocopy=True, locals_builtins=True)
 
     def copy(self):
+        """ Clones the current context, conserving all data and metadata
+        (loader, template cache, ...)
+        """
         return QWebContext(self.cr, self.uid, dict.copy(self),
                            loader=self.loader,
-                           templates=self.templates,
                            context=self.context)
 
     def __copy__(self):
         return self.copy()
 
 class QWeb(orm.AbstractModel):
-    """QWeb Xml templating engine
-
-    The templating engine use a very simple syntax based "magic" xml
-    attributes, to produce textual output (even non-xml).
-
-    The core magic attributes are:
-
-    flow attributes:
-        t-if t-foreach t-call
-
-    output attributes:
-        t-att t-raw t-esc t-trim
+    """ Base QWeb rendering engine
 
-    assignation attribute:
-        t-set
-
-    QWeb can be extended like any OpenERP model and new attributes can be
-    added.
-
-    If you need to customize t-fields rendering, subclass the ir.qweb.field
-    model (and its sub-models) then override :meth:`~.get_converter_for` to
-    fetch the right field converters for your qweb model.
+    * to customize ``t-field`` rendering, subclass ``ir.qweb.field`` and
+      create new models called :samp:`ir.qweb.field.{widget}`
+    * alternatively, override :meth:`~.get_converter_for` and return an
+      arbitrary model to use as field converter
 
     Beware that if you need extensions or alterations which could be
     incompatible with other subsystems, you should create a local object
@@ -113,7 +125,6 @@ class QWeb(orm.AbstractModel):
 
     _name = 'ir.qweb'
 
-    node = xml.dom.Node
     _void_elements = frozenset([
         'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
         'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'])
@@ -150,41 +161,24 @@ class QWeb(orm.AbstractModel):
     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 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)
 
-    def load_document(self, document, res_id, qwebcontext):
-        """
-        Loads an XML document and installs any contained template in the engine
-        """
         if hasattr(document, 'documentElement'):
             dom = document
         elif document.startswith("<?xml"):
-            dom = xml.dom.minidom.parseString(document)
+            dom = etree.fromstring(document)
         else:
-            dom = xml.dom.minidom.parse(document)
+            dom = etree.parse(document).getroot()
 
-        for node in dom.documentElement.childNodes:
-            if node.nodeType == self.node.ELEMENT_NODE:
-                if node.getAttribute('t-name'):
-                    name = str(node.getAttribute("t-name"))
-                    self.add_template(qwebcontext, name, node)
-                if res_id and node.tagName == "t":
-                    self.add_template(qwebcontext, res_id, node)
-                    res_id = None
-
-    def get_template(self, name, qwebcontext):
-        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]
+        res_id = isinstance(name, (int, long)) and name or None
+        for node in dom:
+            if node.get('t-name') or (res_id and node.tag == "t"):
+                return node
 
         raise QWebTemplateNotFound("Template %r not found" % name, template=origin_template)
 
@@ -227,6 +221,15 @@ class QWeb(orm.AbstractModel):
         return int(bool(self.eval(expr, qwebcontext)))
 
     def render(self, cr, uid, id_or_xml_id, qwebcontext=None, loader=None, context=None):
+        """ render(cr, uid, id_or_xml_id, qwebcontext=None, loader=None, context=None)
+
+        Renders the template specified by the provided template name
+
+        :param qwebcontext: context for rendering the template
+        :type qwebcontext: dict or :class:`QWebContext` instance
+        :param loader: if ``qwebcontext`` is a dict, loader set into the
+                       context instantiated for rendering
+        """
         if qwebcontext is None:
             qwebcontext = {}
 
@@ -243,48 +246,54 @@ class QWeb(orm.AbstractModel):
         return self.render_node(self.get_template(id_or_xml_id, qwebcontext), qwebcontext)
 
     def render_node(self, element, qwebcontext):
-        result = ""
-        if element.nodeType == self.node.TEXT_NODE or element.nodeType == self.node.CDATA_SECTION_NODE:
-            result = element.data.encode("utf8")
-        elif element.nodeType == self.node.ELEMENT_NODE:
-            generated_attributes = ""
-            t_render = None
-            template_attributes = {}
-            for (attribute_name, attribute_value) in element.attributes.items():
-                attribute_name = str(attribute_name)
-                if attribute_name == "groups":
-                    cr = qwebcontext.get('request') and qwebcontext['request'].cr or None
-                    uid = qwebcontext.get('request') and qwebcontext['request'].uid or None
-                    can_see = self.user_has_groups(cr, uid, groups=attribute_value) if cr and uid else False
-                    if not can_see:
-                        return ''
-                    continue
-
-                if isinstance(attribute_value, unicode):
-                    attribute_value = attribute_value.encode("utf8")
-                else:
-                    attribute_value = attribute_value.nodeValue.encode("utf8")
-
-                if attribute_name.startswith("t-"):
-                    for attribute in self._render_att:
-                        if attribute_name[2:].startswith(attribute):
-                            att, val = self._render_att[attribute](self, element, attribute_name, attribute_value, qwebcontext)
-                            generated_attributes += val and ' %s="%s"' % (att, werkzeug.utils.escape(val)) or " "
-                            break
-                    else:
-                        if attribute_name[2:] in self._render_tag:
-                            t_render = attribute_name[2:]
-                        template_attributes[attribute_name[2:]] = attribute_value
-                else:
-                    generated_attributes += ' %s="%s"' % (attribute_name, werkzeug.utils.escape(attribute_value))
+        generated_attributes = ""
+        t_render = None
+        template_attributes = {}
 
-            if 'debug' in template_attributes:
-                debugger = template_attributes.get('debug', 'pdb')
+        debugger = element.get('t-debug')
+        if debugger is not None:
+            if openerp.tools.config['dev_mode']:
                 __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)
+                _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":
+                cr = qwebcontext.get('request') and qwebcontext['request'].cr or None
+                uid = qwebcontext.get('request') and qwebcontext['request'].uid or None
+                can_see = self.user_has_groups(cr, uid, groups=attribute_value) if cr and uid else False
+                if not can_see:
+                    return ''
+
+            attribute_value = attribute_value.encode("utf8")
+
+            if attribute_name.startswith("t-"):
+                for attribute in self._render_att:
+                    if attribute_name[2:].startswith(attribute):
+                        attrs = self._render_att[attribute](
+                            self, element, attribute_name, attribute_value, qwebcontext)
+                        for att, val in attrs:
+                            if not val: continue
+                            if not isinstance(val, str):
+                                val = unicode(val).encode('utf-8')
+                            generated_attributes += self.render_attribute(element, att, val, qwebcontext)
+                        break
+                else:
+                    if attribute_name[2:] in self._render_tag:
+                        t_render = attribute_name[2:]
+                    template_attributes[attribute_name[2:]] = attribute_value
+            else:
+                generated_attributes += self.render_attribute(element, attribute_name, attribute_value, qwebcontext)
+
+        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 += self.render_tail(element.tail, element, qwebcontext)
+
         if isinstance(result, unicode):
             return result.encode('utf-8')
         return result
@@ -296,18 +305,18 @@ class QWeb(orm.AbstractModel):
         # qwebcontext: values
         # inner: optional innerXml
         if inner:
-            g_inner = inner
+            g_inner = inner.encode('utf-8') if isinstance(inner, unicode) else inner
         else:
-            g_inner = []
-            for current_node in element.childNodes:
+            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))
                 except QWebException:
                     raise
                 except Exception:
                     template = qwebcontext.get('__template__')
-                    raise_qweb_exception(message="Could not render element %r" % element.nodeName, node=element, template=template)
-        name = str(element.nodeName)
+                    raise_qweb_exception(message="Could not render element %r" % element.tag, node=element, template=template)
+        name = str(element.tag)
         inner = "".join(g_inner)
         trim = template_attributes.get("trim", 0)
         if trim == 0:
@@ -328,17 +337,28 @@ class QWeb(orm.AbstractModel):
         else:
             return "<%s%s/>" % (name, generated_attributes)
 
+    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-"):
-            att, val = attribute_name[7:], self.eval_format(attribute_value, qwebcontext)
-        elif attribute_name.startswith("t-att-"):
-            att, val = attribute_name[6:], self.eval(attribute_value, qwebcontext)
-            if isinstance(val, unicode):
-                val = val.encode("utf8")
-        else:
-            att, val = self.eval_object(attribute_value, qwebcontext)
-        return att, val
+            return [(attribute_name[7:], self.eval_format(attribute_value, qwebcontext))]
+
+        if attribute_name.startswith("t-att-"):
+            return [(attribute_name[6:], self.eval(attribute_value, qwebcontext))]
+
+        result = self.eval_object(attribute_value, qwebcontext)
+        if isinstance(result, collections.Mapping):
+            return result.iteritems()
+        # assume tuple
+        return [result]
 
     # Tags
     def render_tag_raw(self, element, template_attributes, generated_attributes, qwebcontext):
@@ -347,46 +367,58 @@ class QWeb(orm.AbstractModel):
 
     def render_tag_esc(self, element, template_attributes, generated_attributes, qwebcontext):
         options = json.loads(template_attributes.get('esc-options') or '{}')
-        widget = self.get_widget_for(options.get('widget', ''))
+        widget = self.get_widget_for(options.get('widget'))
         inner = widget.format(template_attributes['esc'], options, qwebcontext)
         return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
 
+    def _iterate(self, iterable):
+        if isinstance (iterable, collections.Mapping):
+            return iterable.iteritems()
+
+        return itertools.izip(*itertools.tee(iterable))
+
     def render_tag_foreach(self, element, template_attributes, generated_attributes, qwebcontext):
         expr = template_attributes["foreach"]
         enum = self.eval_object(expr, qwebcontext)
-        if enum is not None:
-            var = template_attributes.get('as', expr).replace('.', '_')
-            copy_qwebcontext = qwebcontext.copy()
-            size = -1
-            if isinstance(enum, (list, tuple)):
-                size = len(enum)
-            elif hasattr(enum, 'count'):
-                size = enum.count()
-            copy_qwebcontext["%s_size" % var] = size
-            copy_qwebcontext["%s_all" % var] = enum
-            index = 0
-            ru = []
-            for i in enum:
-                copy_qwebcontext["%s_value" % var] = i
-                copy_qwebcontext["%s_index" % var] = index
-                copy_qwebcontext["%s_first" % var] = index == 0
-                copy_qwebcontext["%s_even" % var] = index % 2
-                copy_qwebcontext["%s_odd" % var] = (index + 1) % 2
-                copy_qwebcontext["%s_last" % var] = index + 1 == size
-                if index % 2:
-                    copy_qwebcontext["%s_parity" % var] = 'odd'
-                else:
-                    copy_qwebcontext["%s_parity" % var] = 'even'
-                if 'as' in template_attributes:
-                    copy_qwebcontext[var] = i
-                elif isinstance(i, dict):
-                    copy_qwebcontext.update(i)
-                ru.append(self.render_element(element, template_attributes, generated_attributes, copy_qwebcontext))
-                index += 1
-            return "".join(ru)
-        else:
+        if enum is None:
             template = qwebcontext.get('__template__')
             raise QWebException("foreach enumerator %r is not defined while rendering template %r" % (expr, template), template=template)
+        if isinstance(enum, int):
+            enum = range(enum)
+
+        varname = template_attributes['as'].replace('.', '_')
+        copy_qwebcontext = qwebcontext.copy()
+
+        size = None
+        if isinstance(enum, collections.Sized):
+            size = len(enum)
+            copy_qwebcontext["%s_size" % varname] = size
+
+        copy_qwebcontext["%s_all" % varname] = enum
+        ru = []
+        for index, (item, value) in enumerate(self._iterate(enum)):
+            copy_qwebcontext.update({
+                varname: item,
+                '%s_value' % varname: value,
+                '%s_index' % varname: index,
+                '%s_first' % varname: index == 0,
+            })
+            if size is not None:
+                copy_qwebcontext['%s_last' % varname] = index + 1 == size
+            if index % 2:
+                copy_qwebcontext.update({
+                    '%s_parity' % varname: 'odd',
+                    '%s_even' % varname: False,
+                    '%s_odd' % varname: True,
+                })
+            else:
+                copy_qwebcontext.update({
+                    '%s_parity' % varname: 'even',
+                    '%s_even' % varname: True,
+                    '%s_odd' % varname: False,
+                })
+            ru.append(self.render_element(element, template_attributes, generated_attributes, copy_qwebcontext))
+        return "".join(ru)
 
     def render_tag_if(self, element, template_attributes, generated_attributes, qwebcontext):
         if self.eval_bool(template_attributes["if"], qwebcontext):
@@ -408,16 +440,15 @@ class QWeb(orm.AbstractModel):
 
     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"""
-        name = template_attributes['call-assets']
-
-        # Backward compatibility hack for manifest usage
-        qwebcontext['manifest_list'] = openerp.addons.web.controllers.main.manifest_list
-
-        d = qwebcontext.copy()
-        d.context['inherit_branding'] = False
-        content = self.render_tag_call(
-            element, {'call': name}, generated_attributes, d)
-        bundle = AssetsBundle(name, html=content)
+        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')))
@@ -433,7 +464,7 @@ class QWeb(orm.AbstractModel):
 
     def render_tag_field(self, element, template_attributes, generated_attributes, qwebcontext):
         """ eg: <span t-record="browse_record(res.partner, 1)" t-field="phone">+1 555 555 8069</span>"""
-        node_name = element.nodeName
+        node_name = element.tag
         assert node_name not in ("table", "tbody", "thead", "tfoot", "tr", "td",
                                  "li", "ul", "ol", "dl", "dt", "dd"),\
             "RTE widgets do not work correctly on %r elements" % node_name
@@ -443,9 +474,9 @@ class QWeb(orm.AbstractModel):
         record, field_name = template_attributes["field"].rsplit('.', 1)
         record = self.eval_object(record, qwebcontext)
 
-        column = record._model._all_columns[field_name].column
+        field = record._fields[field_name]
         options = json.loads(template_attributes.get('field-options') or '{}')
-        field_type = get_field_type(column, options)
+        field_type = get_field_type(field, options)
 
         converter = self.get_converter_for(field_type)
 
@@ -453,10 +484,24 @@ class QWeb(orm.AbstractModel):
                                  element, template_attributes, generated_attributes, qwebcontext, context=qwebcontext.context)
 
     def get_converter_for(self, field_type):
+        """ returns a :class:`~openerp.models.Model` used to render a
+        ``t-field``.
+
+        By default, tries to get the model named
+        :samp:`ir.qweb.field.{field_type}`, falling back on ``ir.qweb.field``.
+
+        :param str field_type: type or widget of field to render
+        """
         return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
 
     def get_widget_for(self, widget):
-        return self.pool.get('ir.qweb.widget.' + widget, self.pool['ir.qweb.widget'])
+        """ returns a :class:`~openerp.models.Model` used to render a
+        ``t-esc``
+
+        :param str widget: name of the widget to use, or ``None``
+        """
+        widget_model = ('ir.qweb.widget.' + widget) if widget else 'ir.qweb.widget'
+        return self.pool.get(widget_model) or self.pool['ir.qweb.widget']
 
     def get_attr_bool(self, attr, default=False):
         if attr:
@@ -486,50 +531,57 @@ class FieldConverter(osv.AbstractModel):
     def attributes(self, cr, uid, field_name, record, options,
                    source_element, g_att, t_att, qweb_context,
                    context=None):
-        """
+        """ attributes(cr, uid, field_name, record, options, source_element, g_att, t_att, qweb_context, context=None)
+
         Generates the metadata attributes (prefixed by ``data-oe-`` for the
         root node of the field conversion. Attribute values are escaped by the
-        parent using ``werkzeug.utils.escape``.
+        parent.
 
         The default attributes are:
 
         * ``model``, the name of the record's model
         * ``id`` the id of the record to which the field belongs
         * ``field`` the name of the converted field
-        * ``type`` the logical field type (widget, may not match the column's
-          ``type``, may not be any _column subclass name)
+        * ``type`` the logical field type (widget, may not match the field's
+          ``type``, may not be any Field subclass name)
         * ``translate``, a boolean flag (``0`` or ``1``) denoting whether the
-          column is translatable
+          field is translatable
         * ``expression``, the original expression
 
         :returns: iterable of (attribute name, attribute value) pairs.
         """
-        column = record._model._all_columns[field_name].column
-        field_type = get_field_type(column, options)
+        field = record._fields[field_name]
+        field_type = get_field_type(field, options)
         return [
-            ('data-oe-model', record._model._name),
+            ('data-oe-model', record._name),
             ('data-oe-id', record.id),
             ('data-oe-field', field_name),
             ('data-oe-type', field_type),
             ('data-oe-expression', t_att['field']),
         ]
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
-        """ Converts a single value to its HTML version/output
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
+        """ value_to_html(cr, uid, value, field, options=None, context=None)
+
+        Converts a single value to its HTML version/output
         """
         if not value: return ''
         return value
 
-    def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
-        """ Converts the specified field of the browse_record ``record`` to
-        HTML
+    def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
+        """ record_to_html(cr, uid, field_name, record, options=None, context=None)
+
+        Converts the specified field of the browse_record ``record`` to HTML
         """
+        field = record._fields[field_name]
         return self.value_to_html(
-            cr, uid, record[field_name], column, options=options, context=context)
+            cr, uid, record[field_name], field, options=options, context=context)
 
     def to_html(self, cr, uid, field_name, record, options,
                 source_element, t_att, g_att, qweb_context, context=None):
-        """ Converts a ``t-field`` to its HTML output. A ``t-field`` may be
+        """ to_html(cr, uid, field_name, record, options, source_element, t_att, g_att, qweb_context, context=None)
+
+        Converts a ``t-field`` to its HTML output. A ``t-field`` may be
         extended by a ``t-field-options``, which is a JSON-serialized mapping
         of configuration values.
 
@@ -537,23 +589,24 @@ class FieldConverter(osv.AbstractModel):
         field's own ``_type``.
         """
         try:
-            content = self.record_to_html(
-                cr, uid, field_name, record,
-                record._model._all_columns[field_name].column,
-                options, context=context)
+            content = self.record_to_html(cr, uid, field_name, record, options, context=context)
             if options.get('html-escape', True):
-                content = werkzeug.utils.escape(content)
+                content = escape(content)
             elif hasattr(content, '__html__'):
                 content = content.__html__()
         except Exception:
             _logger.warning("Could not get field %s for model %s",
-                            field_name, record._model._name, exc_info=True)
+                            field_name, record._name, exc_info=True)
             content = None
 
-        if context and context.get('inherit_branding'):
+        inherit_branding = context and context.get('inherit_branding')
+        if not inherit_branding and context and context.get('inherit_branding_auto'):
+            inherit_branding = self.pool['ir.model.access'].check(cr, uid, record._name, 'write', False, context=context)
+
+        if inherit_branding:
             # add branding attributes
             g_att += ''.join(
-                ' %s="%s"' % (name, werkzeug.utils.escape(value))
+                ' %s="%s"' % (name, escape(value))
                 for name, value in self.attributes(
                     cr, uid, field_name, record, options,
                     source_element, g_att, t_att, qweb_context)
@@ -567,13 +620,16 @@ class FieldConverter(osv.AbstractModel):
 
     def render_element(self, cr, uid, source_element, t_att, g_att,
                        qweb_context, content):
-        """ Final rendering hook, by default just calls ir.qweb's ``render_element``
+        """ render_element(cr, uid, source_element, t_att, g_att, qweb_context, content)
+
+        Final rendering hook, by default just calls ir.qweb's ``render_element``
         """
         return self.qweb_object().render_element(
             source_element, t_att, g_att, qweb_context, content or '')
 
     def user_lang(self, cr, uid, context):
-        """
+        """ user_lang(cr, uid, context)
+
         Fetches the res.lang object corresponding to the language code stored
         in the user's context. Fallbacks to en_US if no lang is present in the
         context *or the language code is not valid*.
@@ -594,14 +650,14 @@ class FloatConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.float'
     _inherit = 'ir.qweb.field'
 
-    def precision(self, cr, uid, column, options=None, context=None):
-        _, precision = column.digits or (None, None)
+    def precision(self, cr, uid, field, options=None, context=None):
+        _, precision = field.digits or (None, None)
         return precision
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         if context is None:
             context = {}
-        precision = self.precision(cr, uid, column, options=options, context=context)
+        precision = self.precision(cr, uid, field, options=options, context=context)
         fmt = '%f' if precision is None else '%.{precision}f'
 
         lang_code = context.get('lang') or 'en_US'
@@ -612,7 +668,7 @@ class FloatConverter(osv.AbstractModel):
         # it to switch to scientific notation starting at a million *and* to
         # strip decimals. So use %f and if no precision was specified manually
         # strip trailing 0.
-        if not precision:
+        if precision is None:
             formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted)
         return formatted
 
@@ -620,14 +676,14 @@ class DateConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.date'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
-        if not value: return ''
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
+        if not value or len(value)<10: return ''
         lang = self.user_lang(cr, uid, context=context)
         locale = babel.Locale.parse(lang.code)
 
         if isinstance(value, basestring):
             value = datetime.datetime.strptime(
-                value, openerp.tools.DEFAULT_SERVER_DATE_FORMAT)
+                value[:10], openerp.tools.DEFAULT_SERVER_DATE_FORMAT)
 
         if options and 'format' in options:
             pattern = options['format']
@@ -635,7 +691,7 @@ class DateConverter(osv.AbstractModel):
             strftime_pattern = lang.date_format
             pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
 
-        return babel.dates.format_datetime(
+        return babel.dates.format_date(
             value, format=pattern,
             locale=locale)
 
@@ -643,7 +699,7 @@ class DateTimeConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.datetime'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         if not value: return ''
         lang = self.user_lang(cr, uid, context=context)
         locale = babel.Locale.parse(lang.code)
@@ -662,14 +718,14 @@ class DateTimeConverter(osv.AbstractModel):
 
         if options and options.get('hide_seconds'):
             pattern = pattern.replace(":ss", "").replace(":s", "")
-        
+
         return babel.dates.format_datetime(value, format=pattern, locale=locale)
 
 class TextConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.text'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         """
         Escapes the value and converts newlines to br. This is bullshit.
         """
@@ -681,19 +737,19 @@ class SelectionConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.selection'
     _inherit = 'ir.qweb.field'
 
-    def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
+    def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
         value = record[field_name]
         if not value: return ''
-        selection = dict(fields.selection.reify(
-            cr, uid, record._model, column))
+        field = record._fields[field_name]
+        selection = dict(field.get_description(record.env)['selection'])
         return self.value_to_html(
-            cr, uid, selection[value], column, options=options)
+            cr, uid, selection[value], field, options=options)
 
 class ManyToOneConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.many2one'
     _inherit = 'ir.qweb.field'
 
-    def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
+    def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
         [read] = record.read([field_name])
         if not read[field_name]: return ''
         _, value = read[field_name]
@@ -703,7 +759,7 @@ class HTMLConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.html'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         return HTMLSafe(value or '')
 
 class ImageConverter(osv.AbstractModel):
@@ -718,7 +774,7 @@ class ImageConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.image'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         try:
             image = Image.open(cStringIO.StringIO(value.decode('base64')))
             image.verify()
@@ -751,11 +807,11 @@ class MonetaryConverter(osv.AbstractModel):
             cr, uid, field_name, record, options,
             source_element, t_att, g_att, qweb_context, context=context)
 
-    def record_to_html(self, cr, uid, field_name, record, column, options, context=None):
+    def record_to_html(self, cr, uid, field_name, record, options, context=None):
         if context is None:
             context = {}
         Currency = self.pool['res.currency']
-        display = self.display_currency(cr, uid, options)
+        display_currency = self.display_currency(cr, uid, options['display_currency'], options)
 
         # lang.format mandates a sprintf-style format. These formats are non-
         # minimal (they have a default fixed precision instead), and
@@ -766,17 +822,23 @@ class MonetaryConverter(osv.AbstractModel):
         # The log10 of the rounding should be the number of digits involved if
         # negative, if positive clamp to 0 digits and call it a day.
         # nb: int() ~ floor(), we want nearest rounding instead
-        precision = int(round(math.log10(display.rounding)))
+        precision = int(math.floor(math.log10(display_currency.rounding)))
         fmt = "%.{0}f".format(-precision if precision < 0 else 0)
 
+        from_amount = record[field_name]
+
+        if options.get('from_currency'):
+            from_currency = self.display_currency(cr, uid, options['from_currency'], options)
+            from_amount = Currency.compute(cr, uid, from_currency.id, display_currency.id, from_amount)
+
         lang_code = context.get('lang') or 'en_US'
         lang = self.pool['res.lang']
-        formatted_amount = lang.format(cr, uid, [lang_code], 
-            fmt, Currency.round(cr, uid, display, record[field_name]),
+        formatted_amount = lang.format(cr, uid, [lang_code],
+            fmt, Currency.round(cr, uid, display_currency, from_amount),
             grouping=True, monetary=True)
 
         pre = post = u''
-        if display.position == 'before':
+        if display_currency.position == 'before':
             pre = u'{symbol} '
         else:
             post = u' {symbol}'
@@ -785,12 +847,12 @@ class MonetaryConverter(osv.AbstractModel):
             formatted_amount,
             pre=pre, post=post,
         ).format(
-            symbol=display.symbol,
+            symbol=display_currency.symbol,
         ))
 
-    def display_currency(self, cr, uid, options):
+    def display_currency(self, cr, uid, currency, options):
         return self.qweb_object().eval_object(
-            options['display_currency'], options['_qweb_context'])
+            currency, options['_qweb_context'])
 
 TIMEDELTA_UNITS = (
     ('year',   3600 * 24 * 365),
@@ -816,7 +878,7 @@ class DurationConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.duration'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         units = dict(TIMEDELTA_UNITS)
         if value < 0:
             raise ValueError(_("Durations can't be negative"))
@@ -836,13 +898,14 @@ class DurationConverter(osv.AbstractModel):
                 v*secs_per_unit, threshold=1, locale=locale)
             if section:
                 sections.append(section)
-        return u' '.join(sections)
+        return ' '.join(sections)
+
 
 class RelativeDatetimeConverter(osv.AbstractModel):
     _name = 'ir.qweb.field.relative'
     _inherit = 'ir.qweb.field'
 
-    def value_to_html(self, cr, uid, value, column, options=None, context=None):
+    def value_to_html(self, cr, uid, value, field, options=None, context=None):
         parse_format = openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT
         locale = babel.Locale.parse(
             self.user_lang(cr, uid, context=context).code)
@@ -850,8 +913,8 @@ class RelativeDatetimeConverter(osv.AbstractModel):
         if isinstance(value, basestring):
             value = datetime.datetime.strptime(value, parse_format)
 
-        # value should be a naive datetime in UTC. So is fields.datetime.now()
-        reference = datetime.datetime.strptime(column.now(), parse_format)
+        # value should be a naive datetime in UTC. So is fields.Datetime.now()
+        reference = datetime.datetime.strptime(field.now(), parse_format)
 
         return babel.dates.format_timedelta(
             value - reference, add_direction=True, locale=locale)
@@ -860,29 +923,32 @@ class Contact(orm.AbstractModel):
     _name = 'ir.qweb.field.contact'
     _inherit = 'ir.qweb.field.many2one'
 
-    def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
+    def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
+        if context is None:
+            context = {}
+
         if options is None:
             options = {}
         opf = options.get('fields') or ["name", "address", "phone", "mobile", "fax", "email"]
 
-        if not getattr(record, field_name):
+        value_rec = record[field_name]
+        if not value_rec:
             return None
-
-        id = getattr(record, field_name).id
-        field_browse = self.pool[column._obj].browse(cr, openerp.SUPERUSER_ID, id, context={"show_address": True})
-        value = field_browse.name_get()[0][1]
+        value_rec = value_rec.sudo().with_context(show_address=True)
+        value = value_rec.name_get()[0][1]
 
         val = {
             'name': value.split("\n")[0],
-            'address': werkzeug.utils.escape("\n".join(value.split("\n")[1:])),
-            'phone': field_browse.phone,
-            'mobile': field_browse.mobile,
-            'fax': field_browse.fax,
-            'city': field_browse.city,
-            'country_id': field_browse.country_id and field_browse.country_id.name_get()[0][1],
-            'email': field_browse.email,
+            'address': escape("\n".join(value.split("\n")[1:])).strip(),
+            'phone': value_rec.phone,
+            'mobile': value_rec.mobile,
+            'fax': value_rec.fax,
+            'city': value_rec.city,
+            'country_id': value_rec.country_id.display_name,
+            'website': value_rec.website,
+            'email': value_rec.email,
             'fields': opf,
-            'object': field_browse,
+            'object': value_rec,
             'options': options
         }
 
@@ -894,7 +960,7 @@ class QwebView(orm.AbstractModel):
     _name = 'ir.qweb.field.qweb'
     _inherit = 'ir.qweb.field.many2one'
 
-    def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
+    def record_to_html(self, cr, uid, field_name, record, options=None, context=None):
         if not getattr(record, field_name):
             return None
 
@@ -917,7 +983,7 @@ class QwebWidget(osv.AbstractModel):
         return self.pool['ir.qweb'].eval_str(inner, qwebcontext)
 
     def format(self, inner, options, qwebcontext):
-        return werkzeug.utils.escape(self._format(inner, options, qwebcontext))
+        return escape(self._format(inner, options, qwebcontext))
 
 class QwebWidgetMonetary(osv.AbstractModel):
     _name = 'ir.qweb.widget.monetary'
@@ -977,49 +1043,74 @@ def nl2br(string, options=None):
     if options is None: options = {}
 
     if options.get('html-escape', True):
-        string = werkzeug.utils.escape(string)
+        string = escape(string)
     return HTMLSafe(string.replace('\n', '<br>\n'))
 
-def get_field_type(column, options):
-    """ Gets a t-field's effective type from the field's column and its options
-    """
-    return options.get('widget', column._type)
+def get_field_type(field, options):
+    """ Gets a t-field's effective type from the field definition and its options """
+    return options.get('widget', field.type)
+
+class AssetError(Exception):
+    pass
+class AssetNotFound(AssetError):
+    pass
 
 class AssetsBundle(object):
-    cache = openerp.tools.lru.LRU(32)
     rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
+    rx_preprocess_imports = re.compile("""(@import\s?['"]([^'"]+)['"](;?))""")
+    rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
 
-    def __init__(self, xmlid, html=None, debug=False):
-        self.debug = debug
+    def __init__(self, xmlid, debug=False, cr=None, uid=None, context=None, registry=None):
         self.xmlid = xmlid
+        self.cr = request.cr if cr is None else cr
+        self.uid = request.uid if uid is None else uid
+        self.context = request.context if context is None else context
+        self.registry = request.registry if registry is None else registry
         self.javascripts = []
         self.stylesheets = []
+        self.css_errors = []
         self.remains = []
         self._checksum = None
-        if html:
-            self.parse(html)
 
-    def parse(self, html):
-        fragments = lxml.html.fragments_fromstring(html)
+        context = self.context.copy()
+        context['inherit_branding'] = False
+        context['rendering_bundle'] = True
+        self.html = self.registry['ir.ui.view'].render(self.cr, self.uid, xmlid, context=context)
+        self.parse()
+
+    def parse(self):
+        fragments = html.fragments_fromstring(self.html)
         for el in fragments:
             if isinstance(el, basestring):
                 self.remains.append(el)
-            elif isinstance(el, lxml.html.HtmlElement):
-                src = el.get('src')
-                href = el.get('href')
+            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':
-                    self.stylesheets.append(StylesheetAsset(source=el.text))
+                    if atype == 'text/sass' or src.endswith('.sass'):
+                        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):
-                    self.stylesheets.append(StylesheetAsset(url=href))
+                    if href.endswith('.sass') or atype == 'text/sass':
+                        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:
-                    self.javascripts.append(JavascriptAsset(source=el.text))
+                    self.javascripts.append(JavascriptAsset(self, inline=el.text))
                 elif el.tag == 'script' and self.can_aggregate(src):
-                    self.javascripts.append(JavascriptAsset(url=src))
+                    self.javascripts.append(JavascriptAsset(self, url=src))
                 else:
-                    self.remains.append(lxml.html.tostring(el))
+                    self.remains.append(html.tostring(el))
             else:
                 try:
-                    self.remains.append(lxml.html.tostring(el))
+                    self.remains.append(html.tostring(el))
                 except Exception:
                     # notYETimplementederror
                     raise NotImplementedError
@@ -1027,52 +1118,70 @@ class AssetsBundle(object):
     def can_aggregate(self, url):
         return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js'))
 
-    def to_html(self, sep='\n            ', css=True, js=True, debug=False):
+    def to_html(self, sep=None, css=True, js=True, debug=False):
+        if sep is None:
+            sep = '\n            '
         response = []
         if debug:
-            if css:
+            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:
-                response.append('<link href="/web/css/%s" rel="stylesheet"/>' % self.xmlid)
-            if js and self.javascripts:
-                response.append('<script type="text/javascript" src="/web/js/%s"></script>' % self.xmlid)
+                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)
 
-    @openerp.tools.func.lazy_property
+    @lazy_property
     def last_modified(self):
+        """Returns last modified date of linked files"""
         return max(itertools.chain(
             (asset.last_modified for asset in self.javascripts),
             (asset.last_modified for asset in self.stylesheets),
-            [datetime.datetime(1970, 1, 1)],
         ))
 
-    @openerp.tools.func.lazy_property
+    @lazy_property
+    def version(self):
+        return self.checksum[0:7]
+
+    @lazy_property
     def checksum(self):
-        checksum = hashlib.new('sha1')
-        for asset in itertools.chain(self.javascripts, self.stylesheets):
-            checksum.update(asset.content.encode("utf-8"))
-        return checksum.hexdigest()
+        """
+        Not really a full checksum.
+        We compute a SHA1 on the rendered bundle + max linked files last_modified date
+        """
+        check = self.html + str(self.last_modified)
+        return hashlib.sha1(check).hexdigest()
 
     def js(self):
-        key = 'js_' + self.checksum
-        if key not in self.cache:
-            content =';\n'.join(asset.minify() for asset in self.javascripts)
-            self.cache[key] = content
-        if self.debug:
-            return "/*\n%s\n*/\n" % '\n'.join(
-                [asset.url for asset in self.javascripts if asset.url]) + self.cache[key]
-        return self.cache[key]
+        content = self.get_cache('js')
+        if content is None:
+            content = ';\n'.join(asset.minify() for asset in self.javascripts)
+            self.set_cache('js', content)
+        return content
 
     def css(self):
-        key = 'css_' + self.checksum
-        if key not in self.cache:
-            content = '\n'.join(asset.minify() for asset in self.stylesheets)
+        """Generate css content from given bundle"""
+        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):
@@ -1083,101 +1192,258 @@ class AssetsBundle(object):
 
             matches.append(content)
             content = u'\n'.join(matches)
-            self.cache[key] = content
-        if self.debug:
-            return "/*\n%s\n*/\n" % '\n'.join(
-                [asset.url for asset in self.javascripts if asset.url]) + self.cache[key]
-        return self.cache[key]
+            if self.css_errors:
+                return content
+            self.set_cache('css', content)
+
+        return content
+
+    def get_cache(self, type):
+        content = None
+        domain = [('url', '=', '/web/%s/%s/%s' % (type, self.xmlid, self.version))]
+        bundle = self.registry['ir.attachment'].search_read(self.cr, openerp.SUPERUSER_ID, domain, ['datas'], context=self.context)
+        if bundle and bundle[0]['datas']:
+            content = bundle[0]['datas'].decode('base64')
+        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 (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 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, PreprocessedCSS):
+                error += '\n    - %s' % (asset.url if asset.url else '<inline sass>')
+        return error
 
 class WebAsset(object):
-    def __init__(self, source=None, url=None):
-        self.source = source
+    html_url = '%s'
+
+    def __init__(self, bundle, inline=None, url=None):
+        self.id = str(uuid.uuid4())
+        self.bundle = bundle
+        self.inline = inline
         self.url = url
-        self._irattach = None
+        self.cr = bundle.cr
+        self.uid = bundle.uid
+        self.registry = bundle.registry
+        self.context = bundle.context
         self._content = None
-        self.filename = None
-        self.last_modified = None
-        if source:
-            self.last_modified = datetime.datetime(1970, 1, 1)
-        if url:
-            module = filter(None, self.url.split('/'))[0]
+        self._filename = None
+        self._ir_attach = None
+        name = '<inline asset>' if inline else url
+        self.name = "%s defined in bundle '%s'" % (name, bundle.xmlid)
+        if not inline and not url:
+            raise Exception("An asset should either be inlined or url linked")
+
+    def stat(self):
+        if not (self.inline or self._filename or self._ir_attach):
+            addon = filter(None, self.url.split('/'))[0]
             try:
                 # Test url against modules static assets
-                mpath = openerp.http.addons_manifest[module]['addons_path']
-                self.filename = mpath + self.url.replace('/', os.path.sep)
-                self.last_modified = datetime.datetime.fromtimestamp(os.path.getmtime(self.filename))
+                mpath = openerp.http.addons_manifest[addon]['addons_path']
+                self._filename = mpath + self.url.replace('/', os.path.sep)
             except Exception:
                 try:
                     # Test url against ir.attachments
+                    fields = ['__last_update', 'datas', 'mimetype']
                     domain = [('type', '=', 'binary'), ('url', '=', self.url)]
-                    attach = request.registry['ir.attachment'].search_read(request.cr, SUPERUSER_ID, domain, ['__last_update', 'datas', 'mimetype'], context=request.context)
-                    self._irattach = attach[0]
-                    server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
-                    try:
-                        self.last_modified =  datetime.datetime.strptime(attach[0]['__last_update'], server_format + '.%f')
-                    except ValueError:
-                        self.last_modified =  datetime.datetime.strptime(attach[0]['__last_update'], server_format)
+                    ira = self.registry['ir.attachment']
+                    attach = ira.search_read(self.cr, openerp.SUPERUSER_ID, domain, fields, context=self.context)
+                    self._ir_attach = attach[0]
                 except Exception:
-                    raise KeyError("Could not find asset '%s' for '%s' addon" % (self.url, module))
+                    raise AssetNotFound("Could not find %s" % self.name)
+
+    def to_html(self):
+        raise NotImplementedError()
 
-    @openerp.tools.func.lazy_property
+    @lazy_property
+    def last_modified(self):
+        try:
+            self.stat()
+            if self._filename:
+                return datetime.datetime.fromtimestamp(os.path.getmtime(self._filename))
+            elif self._ir_attach:
+                server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
+                last_update = self._ir_attach['__last_update']
+                try:
+                    return datetime.datetime.strptime(last_update, server_format + '.%f')
+                except ValueError:
+                    return datetime.datetime.strptime(last_update, server_format)
+        except Exception:
+            pass
+        return datetime.datetime(1970, 1, 1)
+
+    @property
     def content(self):
-        if self.source:
-            return self.source
-        if self._irattach:
-            return self._irattach['datas'].decode('base64')
-        return self.get_content()
+        if self._content is None:
+            self._content = self.inline or self._fetch_content()
+        return self._content
 
-    def get_content(self):
-        with open(self.filename, 'rb') as fp:
-            return fp.read().decode('utf-8')
+    def _fetch_content(self):
+        """ Fetch content from file or database"""
+        try:
+            self.stat()
+            if self._filename:
+                with open(self._filename, 'rb') as fp:
+                    return fp.read().decode('utf-8')
+            else:
+                return self._ir_attach['datas'].decode('base64')
+        except UnicodeDecodeError:
+            raise AssetError('%s is not utf-8 encoded.' % self.name)
+        except IOError:
+            raise AssetNotFound('File %s does not exist.' % self.name)
+        except:
+            raise AssetError('Could not get content for %s.' % self.name)
 
     def minify(self):
         return self.content
 
+    def with_header(self, content=None):
+        if content is None:
+            content = self.content
+        return '\n/* %s */\n%s' % (self.name, content)
+
 class JavascriptAsset(WebAsset):
     def minify(self):
-        return rjsmin(self.content)
+        return self.with_header(rjsmin(self.content))
+
+    def _fetch_content(self):
+        try:
+            return super(JavascriptAsset, self)._fetch_content()
+        except AssetError, e:
+            return "console.error(%s);" % json.dumps(e.message)
 
     def to_html(self):
         if self.url:
-            return '<script type="text/javascript" src="%s"></script>' % self.url
+            return '<script type="text/javascript" src="%s"></script>' % (self.html_url % self.url)
         else:
-            return '<script type="text/javascript" charset="utf-8">%s</script>' % self.source
+            return '<script type="text/javascript" charset="utf-8">%s</script>' % self.with_header()
 
 class StylesheetAsset(WebAsset):
     rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
     rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
     rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
+    rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
 
-    def _get_content(self):
-        with open(self.filename, 'rb') as fp:
-            firstline = fp.readline()
-            m = re.match(r'@charset "([^"]+)";', firstline)
-            if m:
-                encoding = m.group(1)
-            else:
-                encoding = "utf-8"
-                # "reinject" first line as it's not @charset
-                fp.seek(0)
+    def __init__(self, *args, **kw):
+        self.media = kw.pop('media', None)
+        super(StylesheetAsset, self).__init__(*args, **kw)
 
-            return fp.read().decode(encoding)
+    @property
+    def content(self):
+        content = super(StylesheetAsset, self).content
+        if self.media:
+            content = '@media %s { %s }' % (self.media, content)
+        return content
 
-    def get_content(self):
-        content = self._get_content()
-        if self.url:
+    def _fetch_content(self):
+        try:
+            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,
-            )
-
-            content = self.rx_url.sub(
-                r"url(\1%s/" % (web_dir,),
-                content,
-            )
-        return content
+            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 ''
 
     def minify(self):
         # remove existing sourcemaps, make no sense after re-mini
@@ -1187,13 +1453,90 @@ class StylesheetAsset(WebAsset):
         # space
         content = re.sub(r'\s+', ' ', content)
         content = re.sub(r' *([{}]) *', r'\1', content)
-        return content
+        return self.with_header(content)
 
     def to_html(self):
+        media = (' media="%s"' % werkzeug.utils.escape(self.media)) if self.media else ''
         if self.url:
-            return '<link rel="stylesheet" href="%s" type="text/css"/>' % self.url
+            href = self.html_url % self.url
+            return '<link rel="stylesheet" href="%s" type="text/css"%s/>' % (href, media)
+        else:
+            return '<style type="text/css"%s>%s</style>' % (media, self.with_header())
+
+class PreprocessedCSS(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(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
+                if self.indent == self.reindent:
+                    # Don't reindent the file if identation is the final one (reindent)
+                    raise StopIteration()
+            return ind.replace(self.indent, self.reindent)
+
+        try:
+            content = self.rx_indent.sub(fix_indent, content)
+        except StopIteration:
+            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:
-            return '<style type="text/css">%s</style>' % self.source
+            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.