82aefb3cf84455176609ae89e1acac1709a651f4
[odoo/odoo.git] / openerp / addons / base / ir / ir_qweb.py
1 # -*- coding: utf-8 -*-
2 import collections
3 import cStringIO
4 import datetime
5 import hashlib
6 import json
7 import itertools
8 import logging
9 import math
10 import os
11 import re
12 import sys
13 import textwrap
14 import uuid
15 from subprocess import Popen, PIPE
16 from urlparse import urlparse
17
18 import babel
19 import babel.dates
20 import werkzeug
21 from lxml import etree, html
22 from PIL import Image
23
24 import openerp.http
25 import openerp.tools
26 from openerp.tools.func import lazy_property
27 import openerp.tools.lru
28 from openerp.http import request
29 from openerp.tools.safe_eval import safe_eval as eval
30 from openerp.osv import osv, orm, fields
31 from openerp.tools import html_escape as escape
32 from openerp.tools.translate import _
33
34 _logger = logging.getLogger(__name__)
35
36 #--------------------------------------------------------------------
37 # QWeb template engine
38 #--------------------------------------------------------------------
39 class QWebException(Exception):
40     def __init__(self, message, **kw):
41         Exception.__init__(self, message)
42         self.qweb = dict(kw)
43     def pretty_xml(self):
44         if 'node' not in self.qweb:
45             return ''
46         return etree.tostring(self.qweb['node'], pretty_print=True)
47
48 class QWebTemplateNotFound(QWebException):
49     pass
50
51 def raise_qweb_exception(etype=None, **kw):
52     if etype is None:
53         etype = QWebException
54     orig_type, original, tb = sys.exc_info()
55     try:
56         raise etype, original, tb
57     except etype, e:
58         for k, v in kw.items():
59             e.qweb[k] = v
60         # Will use `raise foo from bar` in python 3 and rename cause to __cause__
61         e.qweb['cause'] = original
62         raise
63
64 class QWebContext(dict):
65     def __init__(self, cr, uid, data, loader=None, templates=None, context=None):
66         self.cr = cr
67         self.uid = uid
68         self.loader = loader
69         self.templates = templates or {}
70         self.context = context
71         dic = dict(data)
72         super(QWebContext, self).__init__(dic)
73         self['defined'] = lambda key: key in self
74
75     def safe_eval(self, expr):
76         locals_dict = collections.defaultdict(lambda: None)
77         locals_dict.update(self)
78         locals_dict.pop('cr', None)
79         locals_dict.pop('loader', None)
80         return eval(expr, None, locals_dict, nocopy=True, locals_builtins=True)
81
82     def copy(self):
83         return QWebContext(self.cr, self.uid, dict.copy(self),
84                            loader=self.loader,
85                            templates=self.templates,
86                            context=self.context)
87
88     def __copy__(self):
89         return self.copy()
90
91 class QWeb(orm.AbstractModel):
92     """QWeb Xml templating engine
93
94     The templating engine use a very simple syntax based "magic" xml
95     attributes, to produce textual output (even non-xml).
96
97     The core magic attributes are:
98
99     flow attributes:
100         t-if t-foreach t-call
101
102     output attributes:
103         t-att t-raw t-esc t-trim
104
105     assignation attribute:
106         t-set
107
108     QWeb can be extended like any OpenERP model and new attributes can be
109     added.
110
111     If you need to customize t-fields rendering, subclass the ir.qweb.field
112     model (and its sub-models) then override :meth:`~.get_converter_for` to
113     fetch the right field converters for your qweb model.
114
115     Beware that if you need extensions or alterations which could be
116     incompatible with other subsystems, you should create a local object
117     inheriting from ``ir.qweb`` and customize that.
118     """
119
120     _name = 'ir.qweb'
121
122     _void_elements = frozenset([
123         'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
124         'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'])
125     _format_regex = re.compile(
126         '(?:'
127             # ruby-style pattern
128             '#\{(.+?)\}'
129         ')|(?:'
130             # jinja-style pattern
131             '\{\{(.+?)\}\}'
132         ')')
133
134     def __init__(self, pool, cr):
135         super(QWeb, self).__init__(pool, cr)
136
137         self._render_tag = self.prefixed_methods('render_tag_')
138         self._render_att = self.prefixed_methods('render_att_')
139
140     def prefixed_methods(self, prefix):
141         """ Extracts all methods prefixed by ``prefix``, and returns a mapping
142         of (t-name, method) where the t-name is the method name with prefix
143         removed and underscore converted to dashes
144
145         :param str prefix:
146         :return: dict
147         """
148         n_prefix = len(prefix)
149         return dict(
150             (name[n_prefix:].replace('_', '-'), getattr(type(self), name))
151             for name in dir(self)
152             if name.startswith(prefix)
153         )
154
155     def register_tag(self, tag, func):
156         self._render_tag[tag] = func
157
158     def add_template(self, qwebcontext, name, node):
159         """Add a parsed template in the context. Used to preprocess templates."""
160         qwebcontext.templates[name] = node
161
162     def load_document(self, document, res_id, qwebcontext):
163         """
164         Loads an XML document and installs any contained template in the engine
165         """
166         if hasattr(document, 'documentElement'):
167             dom = document
168         elif document.startswith("<?xml"):
169             dom = etree.fromstring(document)
170         else:
171             dom = etree.parse(document)
172
173         for node in dom:
174             if node.get('t-name'):
175                 name = str(node.get("t-name"))
176                 self.add_template(qwebcontext, name, node)
177             if res_id and node.tag == "t":
178                 self.add_template(qwebcontext, res_id, node)
179                 res_id = None
180
181     def get_template(self, name, qwebcontext):
182         origin_template = qwebcontext.get('__caller__') or qwebcontext['__stack__'][0]
183         if qwebcontext.loader and name not in qwebcontext.templates:
184             try:
185                 xml_doc = qwebcontext.loader(name)
186             except ValueError:
187                 raise_qweb_exception(QWebTemplateNotFound, message="Loader could not find template %r" % name, template=origin_template)
188             self.load_document(xml_doc, isinstance(name, (int, long)) and name or None, qwebcontext=qwebcontext)
189
190         if name in qwebcontext.templates:
191             return qwebcontext.templates[name]
192
193         raise QWebTemplateNotFound("Template %r not found" % name, template=origin_template)
194
195     def eval(self, expr, qwebcontext):
196         try:
197             return qwebcontext.safe_eval(expr)
198         except Exception:
199             template = qwebcontext.get('__template__')
200             raise_qweb_exception(message="Could not evaluate expression %r" % expr, expression=expr, template=template)
201
202     def eval_object(self, expr, qwebcontext):
203         return self.eval(expr, qwebcontext)
204
205     def eval_str(self, expr, qwebcontext):
206         if expr == "0":
207             return qwebcontext.get(0, '')
208         val = self.eval(expr, qwebcontext)
209         if isinstance(val, unicode):
210             return val.encode("utf8")
211         if val is False or val is None:
212             return ''
213         return str(val)
214
215     def eval_format(self, expr, qwebcontext):
216         expr, replacements = self._format_regex.subn(
217             lambda m: self.eval_str(m.group(1) or m.group(2), qwebcontext),
218             expr
219         )
220
221         if replacements:
222             return expr
223
224         try:
225             return str(expr % qwebcontext)
226         except Exception:
227             template = qwebcontext.get('__template__')
228             raise_qweb_exception(message="Format error for expression %r" % expr, expression=expr, template=template)
229
230     def eval_bool(self, expr, qwebcontext):
231         return int(bool(self.eval(expr, qwebcontext)))
232
233     def render(self, cr, uid, id_or_xml_id, qwebcontext=None, loader=None, context=None):
234         if qwebcontext is None:
235             qwebcontext = {}
236
237         if not isinstance(qwebcontext, QWebContext):
238             qwebcontext = QWebContext(cr, uid, qwebcontext, loader=loader, context=context)
239
240         qwebcontext['__template__'] = id_or_xml_id
241         stack = qwebcontext.get('__stack__', [])
242         if stack:
243             qwebcontext['__caller__'] = stack[-1]
244         stack.append(id_or_xml_id)
245         qwebcontext['__stack__'] = stack
246         qwebcontext['xmlid'] = str(stack[0]) # Temporary fix
247         return self.render_node(self.get_template(id_or_xml_id, qwebcontext), qwebcontext)
248
249     def render_node(self, element, qwebcontext):
250         generated_attributes = ""
251         t_render = None
252         template_attributes = {}
253         for (attribute_name, attribute_value) in element.attrib.iteritems():
254             attribute_name = str(attribute_name)
255             if attribute_name == "groups":
256                 cr = qwebcontext.get('request') and qwebcontext['request'].cr or None
257                 uid = qwebcontext.get('request') and qwebcontext['request'].uid or None
258                 can_see = self.user_has_groups(cr, uid, groups=attribute_value) if cr and uid else False
259                 if not can_see:
260                     return ''
261
262             attribute_value = attribute_value.encode("utf8")
263
264             if attribute_name.startswith("t-"):
265                 for attribute in self._render_att:
266                     if attribute_name[2:].startswith(attribute):
267                         att, val = self._render_att[attribute](self, element, attribute_name, attribute_value, qwebcontext)
268                         if val:
269                             generated_attributes += self.render_attribute(element, att, val, qwebcontext)
270                         break
271                 else:
272                     if attribute_name[2:] in self._render_tag:
273                         t_render = attribute_name[2:]
274                     template_attributes[attribute_name[2:]] = attribute_value
275             else:
276                 generated_attributes += self.render_attribute(element, attribute_name, escape(attribute_value), qwebcontext)
277
278         if 'debug' in template_attributes:
279             debugger = template_attributes.get('debug', 'pdb')
280             __import__(debugger).set_trace()  # pdb, ipdb, pudb, ...
281         if t_render:
282             result = self._render_tag[t_render](self, element, template_attributes, generated_attributes, qwebcontext)
283         else:
284             result = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
285
286         if element.tail:
287             result += element.tail.encode('utf-8')
288
289         if isinstance(result, unicode):
290             return result.encode('utf-8')
291         return result
292
293     def render_element(self, element, template_attributes, generated_attributes, qwebcontext, inner=None):
294         # element: element
295         # template_attributes: t-* attributes
296         # generated_attributes: generated attributes
297         # qwebcontext: values
298         # inner: optional innerXml
299         if inner:
300             g_inner = inner.encode('utf-8') if isinstance(inner, unicode) else inner
301         else:
302             g_inner = [] if element.text is None else [element.text.encode('utf-8')]
303             for current_node in element.iterchildren(tag=etree.Element):
304                 try:
305                     g_inner.append(self.render_node(current_node, qwebcontext))
306                 except QWebException:
307                     raise
308                 except Exception:
309                     template = qwebcontext.get('__template__')
310                     raise_qweb_exception(message="Could not render element %r" % element.tag, node=element, template=template)
311         name = str(element.tag)
312         inner = "".join(g_inner)
313         trim = template_attributes.get("trim", 0)
314         if trim == 0:
315             pass
316         elif trim == 'left':
317             inner = inner.lstrip()
318         elif trim == 'right':
319             inner = inner.rstrip()
320         elif trim == 'both':
321             inner = inner.strip()
322         if name == "t":
323             return inner
324         elif len(inner) or name not in self._void_elements:
325             return "<%s%s>%s</%s>" % tuple(
326                 qwebcontext if isinstance(qwebcontext, str) else qwebcontext.encode('utf-8')
327                 for qwebcontext in (name, generated_attributes, inner, name)
328             )
329         else:
330             return "<%s%s/>" % (name, generated_attributes)
331
332     def render_attribute(self, element, name, value, qwebcontext):
333         return ' %s="%s"' % (name, escape(value))
334
335     # Attributes
336     def render_att_att(self, element, attribute_name, attribute_value, qwebcontext):
337         if attribute_name.startswith("t-attf-"):
338             att, val = attribute_name[7:], self.eval_format(attribute_value, qwebcontext)
339         elif attribute_name.startswith("t-att-"):
340             att, val = attribute_name[6:], self.eval(attribute_value, qwebcontext)
341         else:
342             att, val = self.eval_object(attribute_value, qwebcontext)
343         if val and not isinstance(val, str):
344             val = unicode(val).encode("utf8")
345         return att, val
346
347     # Tags
348     def render_tag_raw(self, element, template_attributes, generated_attributes, qwebcontext):
349         inner = self.eval_str(template_attributes["raw"], qwebcontext)
350         return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
351
352     def render_tag_esc(self, element, template_attributes, generated_attributes, qwebcontext):
353         options = json.loads(template_attributes.get('esc-options') or '{}')
354         widget = self.get_widget_for(options.get('widget'))
355         inner = widget.format(template_attributes['esc'], options, qwebcontext)
356         return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
357
358     def render_tag_foreach(self, element, template_attributes, generated_attributes, qwebcontext):
359         expr = template_attributes["foreach"]
360         enum = self.eval_object(expr, qwebcontext)
361         if enum is None:
362             template = qwebcontext.get('__template__')
363             raise QWebException("foreach enumerator %r is not defined while rendering template %r" % (expr, template), template=template)
364
365         varname = template_attributes['as'].replace('.', '_')
366         copy_qwebcontext = qwebcontext.copy()
367         size = -1
368         if isinstance(enum, collections.Sized):
369             size = len(enum)
370         copy_qwebcontext["%s_size" % varname] = size
371         copy_qwebcontext["%s_all" % varname] = enum
372         ru = []
373         for index, item in enumerate(enum):
374             copy_qwebcontext.update({
375                 varname: item,
376                 '%s_value' % varname: item,
377                 '%s_index' % varname: index,
378                 '%s_first' % varname: index == 0,
379                 '%s_last' % varname: index + 1 == size,
380             })
381             if index % 2:
382                 copy_qwebcontext.update({
383                     '%s_parity' % varname: 'odd',
384                     '%s_even' % varname: False,
385                     '%s_odd' % varname: True,
386                 })
387             else:
388                 copy_qwebcontext.update({
389                     '%s_parity' % varname: 'even',
390                     '%s_even' % varname: True,
391                     '%s_odd' % varname: False,
392                 })
393             ru.append(self.render_element(element, template_attributes, generated_attributes, copy_qwebcontext))
394         return "".join(ru)
395
396     def render_tag_if(self, element, template_attributes, generated_attributes, qwebcontext):
397         if self.eval_bool(template_attributes["if"], qwebcontext):
398             return self.render_element(element, template_attributes, generated_attributes, qwebcontext)
399         return ""
400
401     def render_tag_call(self, element, template_attributes, generated_attributes, qwebcontext):
402         d = qwebcontext.copy()
403         d[0] = self.render_element(element, template_attributes, generated_attributes, d)
404         cr = d.get('request') and d['request'].cr or None
405         uid = d.get('request') and d['request'].uid or None
406
407         template = self.eval_format(template_attributes["call"], d)
408         try:
409             template = int(template)
410         except ValueError:
411             pass
412         return self.render(cr, uid, template, d)
413
414     def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
415         """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
416         if len(element):
417             # An asset bundle is rendered in two differents contexts (when genereting html and
418             # when generating the bundle itself) so they must be qwebcontext free
419             # even '0' variable is forbidden
420             template = qwebcontext.get('__template__')
421             raise QWebException("t-call-assets cannot contain children nodes", template=template)
422         xmlid = template_attributes['call-assets']
423         cr, uid, context = [getattr(qwebcontext, attr) for attr in ('cr', 'uid', 'context')]
424         bundle = AssetsBundle(xmlid, cr=cr, uid=uid, context=context, registry=self.pool)
425         css = self.get_attr_bool(template_attributes.get('css'), default=True)
426         js = self.get_attr_bool(template_attributes.get('js'), default=True)
427         return bundle.to_html(css=css, js=js, debug=bool(qwebcontext.get('debug')))
428
429     def render_tag_set(self, element, template_attributes, generated_attributes, qwebcontext):
430         if "value" in template_attributes:
431             qwebcontext[template_attributes["set"]] = self.eval_object(template_attributes["value"], qwebcontext)
432         elif "valuef" in template_attributes:
433             qwebcontext[template_attributes["set"]] = self.eval_format(template_attributes["valuef"], qwebcontext)
434         else:
435             qwebcontext[template_attributes["set"]] = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
436         return ""
437
438     def render_tag_field(self, element, template_attributes, generated_attributes, qwebcontext):
439         """ eg: <span t-record="browse_record(res.partner, 1)" t-field="phone">+1 555 555 8069</span>"""
440         node_name = element.tag
441         assert node_name not in ("table", "tbody", "thead", "tfoot", "tr", "td",
442                                  "li", "ul", "ol", "dl", "dt", "dd"),\
443             "RTE widgets do not work correctly on %r elements" % node_name
444         assert node_name != 't',\
445             "t-field can not be used on a t element, provide an actual HTML node"
446
447         record, field_name = template_attributes["field"].rsplit('.', 1)
448         record = self.eval_object(record, qwebcontext)
449
450         column = record._all_columns[field_name].column
451         options = json.loads(template_attributes.get('field-options') or '{}')
452         field_type = get_field_type(column, options)
453
454         converter = self.get_converter_for(field_type)
455
456         return converter.to_html(qwebcontext.cr, qwebcontext.uid, field_name, record, options,
457                                  element, template_attributes, generated_attributes, qwebcontext, context=qwebcontext.context)
458
459     def get_converter_for(self, field_type):
460         return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
461
462     def get_widget_for(self, widget):
463         widget_model = ('ir.qweb.widget.' + widget) if widget else 'ir.qweb.widget'
464         return self.pool.get(widget_model) or self.pool['ir.qweb.widget']
465
466     def get_attr_bool(self, attr, default=False):
467         if attr:
468             attr = attr.lower()
469             if attr in ('false', '0'):
470                 return False
471             elif attr in ('true', '1'):
472                 return True
473         return default
474
475 #--------------------------------------------------------------------
476 # QWeb Fields converters
477 #--------------------------------------------------------------------
478
479 class FieldConverter(osv.AbstractModel):
480     """ Used to convert a t-field specification into an output HTML field.
481
482     :meth:`~.to_html` is the entry point of this conversion from QWeb, it:
483
484     * converts the record value to html using :meth:`~.record_to_html`
485     * generates the metadata attributes (``data-oe-``) to set on the root
486       result node
487     * generates the root result node itself through :meth:`~.render_element`
488     """
489     _name = 'ir.qweb.field'
490
491     def attributes(self, cr, uid, field_name, record, options,
492                    source_element, g_att, t_att, qweb_context,
493                    context=None):
494         """
495         Generates the metadata attributes (prefixed by ``data-oe-`` for the
496         root node of the field conversion. Attribute values are escaped by the
497         parent.
498
499         The default attributes are:
500
501         * ``model``, the name of the record's model
502         * ``id`` the id of the record to which the field belongs
503         * ``field`` the name of the converted field
504         * ``type`` the logical field type (widget, may not match the column's
505           ``type``, may not be any _column subclass name)
506         * ``translate``, a boolean flag (``0`` or ``1``) denoting whether the
507           column is translatable
508         * ``expression``, the original expression
509
510         :returns: iterable of (attribute name, attribute value) pairs.
511         """
512         column = record._all_columns[field_name].column
513         field_type = get_field_type(column, options)
514         return [
515             ('data-oe-model', record._name),
516             ('data-oe-id', record.id),
517             ('data-oe-field', field_name),
518             ('data-oe-type', field_type),
519             ('data-oe-expression', t_att['field']),
520         ]
521
522     def value_to_html(self, cr, uid, value, column, options=None, context=None):
523         """ Converts a single value to its HTML version/output
524         """
525         if not value: return ''
526         return value
527
528     def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
529         """ Converts the specified field of the browse_record ``record`` to
530         HTML
531         """
532         return self.value_to_html(
533             cr, uid, record[field_name], column, options=options, context=context)
534
535     def to_html(self, cr, uid, field_name, record, options,
536                 source_element, t_att, g_att, qweb_context, context=None):
537         """ Converts a ``t-field`` to its HTML output. A ``t-field`` may be
538         extended by a ``t-field-options``, which is a JSON-serialized mapping
539         of configuration values.
540
541         A default configuration key is ``widget`` which can override the
542         field's own ``_type``.
543         """
544         try:
545             content = self.record_to_html(
546                 cr, uid, field_name, record,
547                 record._all_columns[field_name].column,
548                 options, context=context)
549             if options.get('html-escape', True):
550                 content = escape(content)
551             elif hasattr(content, '__html__'):
552                 content = content.__html__()
553         except Exception:
554             _logger.warning("Could not get field %s for model %s",
555                             field_name, record._name, exc_info=True)
556             content = None
557
558         if context and context.get('inherit_branding'):
559             # add branding attributes
560             g_att += ''.join(
561                 ' %s="%s"' % (name, escape(value))
562                 for name, value in self.attributes(
563                     cr, uid, field_name, record, options,
564                     source_element, g_att, t_att, qweb_context)
565             )
566
567         return self.render_element(cr, uid, source_element, t_att, g_att,
568                                    qweb_context, content)
569
570     def qweb_object(self):
571         return self.pool['ir.qweb']
572
573     def render_element(self, cr, uid, source_element, t_att, g_att,
574                        qweb_context, content):
575         """ Final rendering hook, by default just calls ir.qweb's ``render_element``
576         """
577         return self.qweb_object().render_element(
578             source_element, t_att, g_att, qweb_context, content or '')
579
580     def user_lang(self, cr, uid, context):
581         """
582         Fetches the res.lang object corresponding to the language code stored
583         in the user's context. Fallbacks to en_US if no lang is present in the
584         context *or the language code is not valid*.
585
586         :returns: res.lang browse_record
587         """
588         if context is None: context = {}
589
590         lang_code = context.get('lang') or 'en_US'
591         Lang = self.pool['res.lang']
592
593         lang_ids = Lang.search(cr, uid, [('code', '=', lang_code)], context=context) \
594                or  Lang.search(cr, uid, [('code', '=', 'en_US')], context=context)
595
596         return Lang.browse(cr, uid, lang_ids[0], context=context)
597
598 class FloatConverter(osv.AbstractModel):
599     _name = 'ir.qweb.field.float'
600     _inherit = 'ir.qweb.field'
601
602     def precision(self, cr, uid, column, options=None, context=None):
603         _, precision = column.digits or (None, None)
604         return precision
605
606     def value_to_html(self, cr, uid, value, column, options=None, context=None):
607         if context is None:
608             context = {}
609         precision = self.precision(cr, uid, column, options=options, context=context)
610         fmt = '%f' if precision is None else '%.{precision}f'
611
612         lang_code = context.get('lang') or 'en_US'
613         lang = self.pool['res.lang']
614         formatted = lang.format(cr, uid, [lang_code], fmt.format(precision=precision), value, grouping=True)
615
616         # %f does not strip trailing zeroes. %g does but its precision causes
617         # it to switch to scientific notation starting at a million *and* to
618         # strip decimals. So use %f and if no precision was specified manually
619         # strip trailing 0.
620         if precision is None:
621             formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted)
622         return formatted
623
624 class DateConverter(osv.AbstractModel):
625     _name = 'ir.qweb.field.date'
626     _inherit = 'ir.qweb.field'
627
628     def value_to_html(self, cr, uid, value, column, options=None, context=None):
629         if not value: return ''
630         lang = self.user_lang(cr, uid, context=context)
631         locale = babel.Locale.parse(lang.code)
632
633         if isinstance(value, basestring):
634             value = datetime.datetime.strptime(
635                 value, openerp.tools.DEFAULT_SERVER_DATE_FORMAT)
636
637         if options and 'format' in options:
638             pattern = options['format']
639         else:
640             strftime_pattern = lang.date_format
641             pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
642
643         return babel.dates.format_date(
644             value, format=pattern,
645             locale=locale)
646
647 class DateTimeConverter(osv.AbstractModel):
648     _name = 'ir.qweb.field.datetime'
649     _inherit = 'ir.qweb.field'
650
651     def value_to_html(self, cr, uid, value, column, options=None, context=None):
652         if not value: return ''
653         lang = self.user_lang(cr, uid, context=context)
654         locale = babel.Locale.parse(lang.code)
655
656         if isinstance(value, basestring):
657             value = datetime.datetime.strptime(
658                 value, openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT)
659         value = fields.datetime.context_timestamp(
660             cr, uid, timestamp=value, context=context)
661
662         if options and 'format' in options:
663             pattern = options['format']
664         else:
665             strftime_pattern = (u"%s %s" % (lang.date_format, lang.time_format))
666             pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
667
668         if options and options.get('hide_seconds'):
669             pattern = pattern.replace(":ss", "").replace(":s", "")
670
671         return babel.dates.format_datetime(value, format=pattern, locale=locale)
672
673 class TextConverter(osv.AbstractModel):
674     _name = 'ir.qweb.field.text'
675     _inherit = 'ir.qweb.field'
676
677     def value_to_html(self, cr, uid, value, column, options=None, context=None):
678         """
679         Escapes the value and converts newlines to br. This is bullshit.
680         """
681         if not value: return ''
682
683         return nl2br(value, options=options)
684
685 class SelectionConverter(osv.AbstractModel):
686     _name = 'ir.qweb.field.selection'
687     _inherit = 'ir.qweb.field'
688
689     def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
690         value = record[field_name]
691         if not value: return ''
692         selection = dict(fields.selection.reify(
693             cr, uid, record._model, column, context=context))
694         return self.value_to_html(
695             cr, uid, selection[value], column, options=options)
696
697 class ManyToOneConverter(osv.AbstractModel):
698     _name = 'ir.qweb.field.many2one'
699     _inherit = 'ir.qweb.field'
700
701     def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
702         [read] = record.read([field_name])
703         if not read[field_name]: return ''
704         _, value = read[field_name]
705         return nl2br(value, options=options)
706
707 class HTMLConverter(osv.AbstractModel):
708     _name = 'ir.qweb.field.html'
709     _inherit = 'ir.qweb.field'
710
711     def value_to_html(self, cr, uid, value, column, options=None, context=None):
712         return HTMLSafe(value or '')
713
714 class ImageConverter(osv.AbstractModel):
715     """ ``image`` widget rendering, inserts a data:uri-using image tag in the
716     document. May be overridden by e.g. the website module to generate links
717     instead.
718
719     .. todo:: what happens if different output need different converters? e.g.
720               reports may need embedded images or FS links whereas website
721               needs website-aware
722     """
723     _name = 'ir.qweb.field.image'
724     _inherit = 'ir.qweb.field'
725
726     def value_to_html(self, cr, uid, value, column, options=None, context=None):
727         try:
728             image = Image.open(cStringIO.StringIO(value.decode('base64')))
729             image.verify()
730         except IOError:
731             raise ValueError("Non-image binary fields can not be converted to HTML")
732         except: # image.verify() throws "suitable exceptions", I have no idea what they are
733             raise ValueError("Invalid image content")
734
735         return HTMLSafe('<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value))
736
737 class MonetaryConverter(osv.AbstractModel):
738     """ ``monetary`` converter, has a mandatory option
739     ``display_currency``.
740
741     The currency is used for formatting *and rounding* of the float value. It
742     is assumed that the linked res_currency has a non-empty rounding value and
743     res.currency's ``round`` method is used to perform rounding.
744
745     .. note:: the monetary converter internally adds the qweb context to its
746               options mapping, so that the context is available to callees.
747               It's set under the ``_qweb_context`` key.
748     """
749     _name = 'ir.qweb.field.monetary'
750     _inherit = 'ir.qweb.field'
751
752     def to_html(self, cr, uid, field_name, record, options,
753                 source_element, t_att, g_att, qweb_context, context=None):
754         options['_qweb_context'] = qweb_context
755         return super(MonetaryConverter, self).to_html(
756             cr, uid, field_name, record, options,
757             source_element, t_att, g_att, qweb_context, context=context)
758
759     def record_to_html(self, cr, uid, field_name, record, column, options, context=None):
760         if context is None:
761             context = {}
762         Currency = self.pool['res.currency']
763         display_currency = self.display_currency(cr, uid, options['display_currency'], options)
764
765         # lang.format mandates a sprintf-style format. These formats are non-
766         # minimal (they have a default fixed precision instead), and
767         # lang.format will not set one by default. currency.round will not
768         # provide one either. So we need to generate a precision value
769         # (integer > 0) from the currency's rounding (a float generally < 1.0).
770         #
771         # The log10 of the rounding should be the number of digits involved if
772         # negative, if positive clamp to 0 digits and call it a day.
773         # nb: int() ~ floor(), we want nearest rounding instead
774         precision = int(round(math.log10(display_currency.rounding)))
775         fmt = "%.{0}f".format(-precision if precision < 0 else 0)
776
777         from_amount = record[field_name]
778
779         if options.get('from_currency'):
780             from_currency = self.display_currency(cr, uid, options['from_currency'], options)
781             from_amount = Currency.compute(cr, uid, from_currency.id, display_currency.id, from_amount)
782
783         lang_code = context.get('lang') or 'en_US'
784         lang = self.pool['res.lang']
785         formatted_amount = lang.format(cr, uid, [lang_code],
786             fmt, Currency.round(cr, uid, display_currency, from_amount),
787             grouping=True, monetary=True)
788
789         pre = post = u''
790         if display_currency.position == 'before':
791             pre = u'{symbol} '
792         else:
793             post = u' {symbol}'
794
795         return HTMLSafe(u'{pre}<span class="oe_currency_value">{0}</span>{post}'.format(
796             formatted_amount,
797             pre=pre, post=post,
798         ).format(
799             symbol=display_currency.symbol,
800         ))
801
802     def display_currency(self, cr, uid, currency, options):
803         return self.qweb_object().eval_object(
804             currency, options['_qweb_context'])
805
806 TIMEDELTA_UNITS = (
807     ('year',   3600 * 24 * 365),
808     ('month',  3600 * 24 * 30),
809     ('week',   3600 * 24 * 7),
810     ('day',    3600 * 24),
811     ('hour',   3600),
812     ('minute', 60),
813     ('second', 1)
814 )
815 class DurationConverter(osv.AbstractModel):
816     """ ``duration`` converter, to display integral or fractional values as
817     human-readable time spans (e.g. 1.5 as "1 hour 30 minutes").
818
819     Can be used on any numerical field.
820
821     Has a mandatory option ``unit`` which can be one of ``second``, ``minute``,
822     ``hour``, ``day``, ``week`` or ``year``, used to interpret the numerical
823     field value before converting it.
824
825     Sub-second values will be ignored.
826     """
827     _name = 'ir.qweb.field.duration'
828     _inherit = 'ir.qweb.field'
829
830     def value_to_html(self, cr, uid, value, column, options=None, context=None):
831         units = dict(TIMEDELTA_UNITS)
832         if value < 0:
833             raise ValueError(_("Durations can't be negative"))
834         if not options or options.get('unit') not in units:
835             raise ValueError(_("A unit must be provided to duration widgets"))
836
837         locale = babel.Locale.parse(
838             self.user_lang(cr, uid, context=context).code)
839         factor = units[options['unit']]
840
841         sections = []
842         r = value * factor
843         for unit, secs_per_unit in TIMEDELTA_UNITS:
844             v, r = divmod(r, secs_per_unit)
845             if not v: continue
846             section = babel.dates.format_timedelta(
847                 v*secs_per_unit, threshold=1, locale=locale)
848             if section:
849                 sections.append(section)
850         return ' '.join(sections)
851
852
853 class RelativeDatetimeConverter(osv.AbstractModel):
854     _name = 'ir.qweb.field.relative'
855     _inherit = 'ir.qweb.field'
856
857     def value_to_html(self, cr, uid, value, column, options=None, context=None):
858         parse_format = openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT
859         locale = babel.Locale.parse(
860             self.user_lang(cr, uid, context=context).code)
861
862         if isinstance(value, basestring):
863             value = datetime.datetime.strptime(value, parse_format)
864
865         # value should be a naive datetime in UTC. So is fields.datetime.now()
866         reference = datetime.datetime.strptime(column.now(), parse_format)
867
868         return babel.dates.format_timedelta(
869             value - reference, add_direction=True, locale=locale)
870
871 class Contact(orm.AbstractModel):
872     _name = 'ir.qweb.field.contact'
873     _inherit = 'ir.qweb.field.many2one'
874
875     def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
876         if context is None:
877             context = {}
878
879         if options is None:
880             options = {}
881         opf = options.get('fields') or ["name", "address", "phone", "mobile", "fax", "email"]
882         if not getattr(record, field_name):
883             return None
884
885         id = getattr(record, field_name).id
886         context.update(show_address=True)
887         field_browse = self.pool[column._obj].browse(cr, openerp.SUPERUSER_ID, id, context=context)
888         value = field_browse.name_get()[0][1]
889
890         val = {
891             'name': value.split("\n")[0],
892             'address': escape("\n".join(value.split("\n")[1:])),
893             'phone': field_browse.phone,
894             'mobile': field_browse.mobile,
895             'fax': field_browse.fax,
896             'city': field_browse.city,
897             'country_id': field_browse.country_id.display_name,
898             'website': field_browse.website,
899             'email': field_browse.email,
900             'fields': opf,
901             'object': field_browse,
902             'options': options
903         }
904
905         html = self.pool["ir.ui.view"].render(cr, uid, "base.contact", val, engine='ir.qweb', context=context).decode('utf8')
906
907         return HTMLSafe(html)
908
909 class QwebView(orm.AbstractModel):
910     _name = 'ir.qweb.field.qweb'
911     _inherit = 'ir.qweb.field.many2one'
912
913     def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
914         if not getattr(record, field_name):
915             return None
916
917         view = getattr(record, field_name)
918
919         if view._model._name != "ir.ui.view":
920             _logger.warning("%s.%s must be a 'ir.ui.view' model." % (record, field_name))
921             return None
922
923         ctx = (context or {}).copy()
924         ctx['object'] = record
925         html = view.render(ctx, engine='ir.qweb', context=ctx).decode('utf8')
926
927         return HTMLSafe(html)
928
929 class QwebWidget(osv.AbstractModel):
930     _name = 'ir.qweb.widget'
931
932     def _format(self, inner, options, qwebcontext):
933         return self.pool['ir.qweb'].eval_str(inner, qwebcontext)
934
935     def format(self, inner, options, qwebcontext):
936         return escape(self._format(inner, options, qwebcontext))
937
938 class QwebWidgetMonetary(osv.AbstractModel):
939     _name = 'ir.qweb.widget.monetary'
940     _inherit = 'ir.qweb.widget'
941
942     def _format(self, inner, options, qwebcontext):
943         inner = self.pool['ir.qweb'].eval(inner, qwebcontext)
944         display = self.pool['ir.qweb'].eval_object(options['display_currency'], qwebcontext)
945         precision = int(round(math.log10(display.rounding)))
946         fmt = "%.{0}f".format(-precision if precision < 0 else 0)
947         lang_code = qwebcontext.context.get('lang') or 'en_US'
948         formatted_amount = self.pool['res.lang'].format(
949             qwebcontext.cr, qwebcontext.uid, [lang_code], fmt, inner, grouping=True, monetary=True
950         )
951         pre = post = u''
952         if display.position == 'before':
953             pre = u'{symbol} '
954         else:
955             post = u' {symbol}'
956
957         return u'{pre}{0}{post}'.format(
958             formatted_amount, pre=pre, post=post
959         ).format(symbol=display.symbol,)
960
961 class HTMLSafe(object):
962     """ HTMLSafe string wrapper, Werkzeug's escape() has special handling for
963     objects with a ``__html__`` methods but AFAIK does not provide any such
964     object.
965
966     Wrapping a string in HTML will prevent its escaping
967     """
968     __slots__ = ['string']
969     def __init__(self, string):
970         self.string = string
971     def __html__(self):
972         return self.string
973     def __str__(self):
974         s = self.string
975         if isinstance(s, unicode):
976             return s.encode('utf-8')
977         return s
978     def __unicode__(self):
979         s = self.string
980         if isinstance(s, str):
981             return s.decode('utf-8')
982         return s
983
984 def nl2br(string, options=None):
985     """ Converts newlines to HTML linebreaks in ``string``. Automatically
986     escapes content unless options['html-escape'] is set to False, and returns
987     the result wrapped in an HTMLSafe object.
988
989     :param str string:
990     :param dict options:
991     :rtype: HTMLSafe
992     """
993     if options is None: options = {}
994
995     if options.get('html-escape', True):
996         string = escape(string)
997     return HTMLSafe(string.replace('\n', '<br>\n'))
998
999 def get_field_type(column, options):
1000     """ Gets a t-field's effective type from the field's column and its options
1001     """
1002     return options.get('widget', column._type)
1003
1004 class AssetError(Exception):
1005     pass
1006 class AssetNotFound(AssetError):
1007     pass
1008
1009 class AssetsBundle(object):
1010     # Sass installation:
1011     #
1012     #       sudo gem install sass compass bootstrap-sass
1013     #
1014     # If the following error is encountered:
1015     #       'ERROR: Cannot load compass.'
1016     # Use this:
1017     #       sudo gem install compass --pre
1018     cmd_sass = ['sass', '--stdin', '-t', 'compressed', '--unix-newlines', '--compass', '-r', 'bootstrap-sass']
1019     rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
1020     rx_sass_import = re.compile("""(@import\s?['"]([^'"]+)['"])""")
1021     rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
1022
1023     def __init__(self, xmlid, debug=False, cr=None, uid=None, context=None, registry=None):
1024         self.xmlid = xmlid
1025         self.cr = request.cr if cr is None else cr
1026         self.uid = request.uid if uid is None else uid
1027         self.context = request.context if context is None else context
1028         self.registry = request.registry if registry is None else registry
1029         self.javascripts = []
1030         self.stylesheets = []
1031         self.css_errors = []
1032         self.remains = []
1033         self._checksum = None
1034
1035         context = self.context.copy()
1036         context['inherit_branding'] = False
1037         self.html = self.registry['ir.ui.view'].render(self.cr, self.uid, xmlid, context=context)
1038         self.parse()
1039
1040     def parse(self):
1041         fragments = html.fragments_fromstring(self.html)
1042         for el in fragments:
1043             if isinstance(el, basestring):
1044                 self.remains.append(el)
1045             elif isinstance(el, html.HtmlElement):
1046                 src = el.get('src', '')
1047                 href = el.get('href', '')
1048                 atype = el.get('type')
1049                 media = el.get('media')
1050                 if el.tag == 'style':
1051                     if atype == 'text/sass' or src.endswith('.sass'):
1052                         self.stylesheets.append(SassAsset(self, inline=el.text, media=media))
1053                     else:
1054                         self.stylesheets.append(StylesheetAsset(self, inline=el.text, media=media))
1055                 elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href):
1056                     if href.endswith('.sass') or atype == 'text/sass':
1057                         self.stylesheets.append(SassAsset(self, url=href, media=media))
1058                     else:
1059                         self.stylesheets.append(StylesheetAsset(self, url=href, media=media))
1060                 elif el.tag == 'script' and not src:
1061                     self.javascripts.append(JavascriptAsset(self, inline=el.text))
1062                 elif el.tag == 'script' and self.can_aggregate(src):
1063                     self.javascripts.append(JavascriptAsset(self, url=src))
1064                 else:
1065                     self.remains.append(html.tostring(el))
1066             else:
1067                 try:
1068                     self.remains.append(html.tostring(el))
1069                 except Exception:
1070                     # notYETimplementederror
1071                     raise NotImplementedError
1072
1073     def can_aggregate(self, url):
1074         return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js'))
1075
1076     def to_html(self, sep=None, css=True, js=True, debug=False):
1077         if sep is None:
1078             sep = '\n            '
1079         response = []
1080         if debug:
1081             if css and self.stylesheets:
1082                 self.compile_sass()
1083                 for style in self.stylesheets:
1084                     response.append(style.to_html())
1085             if js:
1086                 for jscript in self.javascripts:
1087                     response.append(jscript.to_html())
1088         else:
1089             if css and self.stylesheets:
1090                 response.append('<link href="/web/css/%s/%s" rel="stylesheet"/>' % (self.xmlid, self.version))
1091             if js:
1092                 response.append('<script type="text/javascript" src="/web/js/%s/%s"></script>' % (self.xmlid, self.version))
1093         response.extend(self.remains)
1094         return sep + sep.join(response)
1095
1096     @lazy_property
1097     def last_modified(self):
1098         """Returns last modified date of linked files"""
1099         return max(itertools.chain(
1100             (asset.last_modified for asset in self.javascripts),
1101             (asset.last_modified for asset in self.stylesheets),
1102         ))
1103
1104     @lazy_property
1105     def version(self):
1106         return self.checksum[0:7]
1107
1108     @lazy_property
1109     def checksum(self):
1110         """
1111         Not really a full checksum.
1112         We compute a SHA1 on the rendered bundle + max linked files last_modified date
1113         """
1114         check = self.html + str(self.last_modified)
1115         return hashlib.sha1(check).hexdigest()
1116
1117     def js(self):
1118         content = self.get_cache('js')
1119         if content is None:
1120             content = ';\n'.join(asset.minify() for asset in self.javascripts)
1121             self.set_cache('js', content)
1122         return content
1123
1124     def css(self):
1125         content = self.get_cache('css')
1126         if content is None:
1127             self.compile_sass()
1128             content = '\n'.join(asset.minify() for asset in self.stylesheets)
1129
1130             if self.css_errors:
1131                 msg = '\n'.join(self.css_errors)
1132                 content += self.css_message(msg.replace('\n', '\\A '))
1133
1134             # move up all @import rules to the top
1135             matches = []
1136             def push(matchobj):
1137                 matches.append(matchobj.group(0))
1138                 return ''
1139
1140             content = re.sub(self.rx_css_import, push, content)
1141
1142             matches.append(content)
1143             content = u'\n'.join(matches)
1144             if self.css_errors:
1145                 return content
1146             self.set_cache('css', content)
1147
1148         return content
1149
1150     def get_cache(self, type):
1151         content = None
1152         domain = [('url', '=', '/web/%s/%s/%s' % (type, self.xmlid, self.version))]
1153         bundle = self.registry['ir.attachment'].search_read(self.cr, self.uid, domain, ['datas'], context=self.context)
1154         if bundle and bundle[0]['datas']:
1155             content = bundle[0]['datas'].decode('base64')
1156         return content
1157
1158     def set_cache(self, type, content):
1159         ira = self.registry['ir.attachment']
1160         url_prefix = '/web/%s/%s/' % (type, self.xmlid)
1161         # Invalidate previous caches
1162         oids = ira.search(self.cr, self.uid, [('url', '=like', url_prefix + '%')], context=self.context)
1163         if oids:
1164             ira.unlink(self.cr, openerp.SUPERUSER_ID, oids, context=self.context)
1165         url = url_prefix + self.version
1166         ira.create(self.cr, openerp.SUPERUSER_ID, dict(
1167                     datas=content.encode('utf8').encode('base64'),
1168                     type='binary',
1169                     name=url,
1170                     url=url,
1171                 ), context=self.context)
1172
1173     def css_message(self, message):
1174         return """
1175             body:before {
1176                 background: #ffc;
1177                 width: 100%%;
1178                 font-size: 14px;
1179                 font-family: monospace;
1180                 white-space: pre;
1181                 content: "%s";
1182             }
1183         """ % message.replace('"', '\\"')
1184
1185     def compile_sass(self):
1186         """
1187             Checks if the bundle contains any sass content, then compiles it to css.
1188             Css compilation is done at the bundle level and not in the assets
1189             because they are potentially interdependant.
1190         """
1191         sass = [asset for asset in self.stylesheets if isinstance(asset, SassAsset)]
1192         if not sass:
1193             return
1194         source = '\n'.join([asset.get_source() for asset in sass])
1195
1196         # move up all @import rules to the top and exclude file imports
1197         imports = []
1198         def push(matchobj):
1199             ref = matchobj.group(2)
1200             line = '@import "%s"' % ref
1201             if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
1202                 imports.append(line)
1203             return ''
1204         source = re.sub(self.rx_sass_import, push, source)
1205         imports.append(source)
1206         source = u'\n'.join(imports)
1207
1208         try:
1209             compiler = Popen(self.cmd_sass, stdin=PIPE, stdout=PIPE, stderr=PIPE)
1210         except Exception:
1211             msg = "Could not find 'sass' program needed to compile sass/scss files"
1212             _logger.error(msg)
1213             self.css_errors.append(msg)
1214             return
1215         result = compiler.communicate(input=source.encode('utf-8'))
1216         if compiler.returncode:
1217             error = self.get_sass_error(''.join(result), source=source)
1218             _logger.warning(error)
1219             self.css_errors.append(error)
1220             return
1221         compiled = result[0].strip().decode('utf8')
1222         fragments = self.rx_css_split.split(compiled)[1:]
1223         while fragments:
1224             asset_id = fragments.pop(0)
1225             asset = next(asset for asset in sass if asset.id == asset_id)
1226             asset._content = fragments.pop(0)
1227
1228     def get_sass_error(self, stderr, source=None):
1229         # TODO: try to find out which asset the error belongs to
1230         error = stderr.split('Load paths')[0].replace('  Use --trace for backtrace.', '')
1231         error += "This error occured while compiling the bundle '%s' containing:" % self.xmlid
1232         for asset in self.stylesheets:
1233             if isinstance(asset, SassAsset):
1234                 error += '\n    - %s' % (asset.url if asset.url else '<inline sass>')
1235         return error
1236
1237 class WebAsset(object):
1238     html_url = '%s'
1239
1240     def __init__(self, bundle, inline=None, url=None):
1241         self.id = str(uuid.uuid4())
1242         self.bundle = bundle
1243         self.inline = inline
1244         self.url = url
1245         self.cr = bundle.cr
1246         self.uid = bundle.uid
1247         self.registry = bundle.registry
1248         self.context = bundle.context
1249         self._content = None
1250         self._filename = None
1251         self._ir_attach = None
1252         name = '<inline asset>' if inline else url
1253         self.name = "%s defined in bundle '%s'" % (name, bundle.xmlid)
1254         if not inline and not url:
1255             raise Exception("An asset should either be inlined or url linked")
1256
1257     def stat(self):
1258         if not (self.inline or self._filename or self._ir_attach):
1259             addon = filter(None, self.url.split('/'))[0]
1260             try:
1261                 # Test url against modules static assets
1262                 mpath = openerp.http.addons_manifest[addon]['addons_path']
1263                 self._filename = mpath + self.url.replace('/', os.path.sep)
1264             except Exception:
1265                 try:
1266                     # Test url against ir.attachments
1267                     fields = ['__last_update', 'datas', 'mimetype']
1268                     domain = [('type', '=', 'binary'), ('url', '=', self.url)]
1269                     ira = self.registry['ir.attachment']
1270                     attach = ira.search_read(self.cr, self.uid, domain, fields, context=self.context)
1271                     self._ir_attach = attach[0]
1272                 except Exception:
1273                     raise AssetNotFound("Could not find %s" % self.name)
1274
1275     def to_html(self):
1276         raise NotImplementedError()
1277
1278     @lazy_property
1279     def last_modified(self):
1280         try:
1281             self.stat()
1282             if self._filename:
1283                 return datetime.datetime.fromtimestamp(os.path.getmtime(self._filename))
1284             elif self._ir_attach:
1285                 server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
1286                 last_update = self._ir_attach['__last_update']
1287                 try:
1288                     return datetime.datetime.strptime(last_update, server_format + '.%f')
1289                 except ValueError:
1290                     return datetime.datetime.strptime(last_update, server_format)
1291         except Exception:
1292             pass
1293         return datetime.datetime(1970, 1, 1)
1294
1295     @property
1296     def content(self):
1297         if not self._content:
1298             self._content = self.inline or self._fetch_content()
1299         return self._content
1300
1301     def _fetch_content(self):
1302         """ Fetch content from file or database"""
1303         try:
1304             self.stat()
1305             if self._filename:
1306                 with open(self._filename, 'rb') as fp:
1307                     return fp.read().decode('utf-8')
1308             else:
1309                 return self._ir_attach['datas'].decode('base64')
1310         except UnicodeDecodeError:
1311             raise AssetError('%s is not utf-8 encoded.' % self.name)
1312         except IOError:
1313             raise AssetNotFound('File %s does not exist.' % self.name)
1314         except:
1315             raise AssetError('Could not get content for %s.' % self.name)
1316
1317     def minify(self):
1318         return self.content
1319
1320     def with_header(self, content=None):
1321         if content is None:
1322             content = self.content
1323         return '\n/* %s */\n%s' % (self.name, content)
1324
1325 class JavascriptAsset(WebAsset):
1326     def minify(self):
1327         return self.with_header(rjsmin(self.content))
1328
1329     def _fetch_content(self):
1330         try:
1331             return super(JavascriptAsset, self)._fetch_content()
1332         except AssetError, e:
1333             return "console.error(%s);" % json.dumps(e.message)
1334
1335     def to_html(self):
1336         if self.url:
1337             return '<script type="text/javascript" src="%s"></script>' % (self.html_url % self.url)
1338         else:
1339             return '<script type="text/javascript" charset="utf-8">%s</script>' % self.with_header()
1340
1341 class StylesheetAsset(WebAsset):
1342     rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
1343     rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
1344     rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
1345     rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
1346
1347     def __init__(self, *args, **kw):
1348         self.media = kw.pop('media', None)
1349         super(StylesheetAsset, self).__init__(*args, **kw)
1350
1351     @property
1352     def content(self):
1353         content = super(StylesheetAsset, self).content
1354         if self.media:
1355             content = '@media %s { %s }' % (self.media, content)
1356         return content
1357
1358     def _fetch_content(self):
1359         try:
1360             content = super(StylesheetAsset, self)._fetch_content()
1361             web_dir = os.path.dirname(self.url)
1362
1363             content = self.rx_import.sub(
1364                 r"""@import \1%s/""" % (web_dir,),
1365                 content,
1366             )
1367
1368             content = self.rx_url.sub(
1369                 r"url(\1%s/" % (web_dir,),
1370                 content,
1371             )
1372
1373             # remove charset declarations, we only support utf-8
1374             content = self.rx_charset.sub('', content)
1375         except AssetError, e:
1376             self.bundle.css_errors.append(e.message)
1377             return ''
1378         return content
1379
1380     def minify(self):
1381         # remove existing sourcemaps, make no sense after re-mini
1382         content = self.rx_sourceMap.sub('', self.content)
1383         # comments
1384         content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
1385         # space
1386         content = re.sub(r'\s+', ' ', content)
1387         content = re.sub(r' *([{}]) *', r'\1', content)
1388         return self.with_header(content)
1389
1390     def to_html(self):
1391         media = (' media="%s"' % werkzeug.utils.escape(self.media)) if self.media else ''
1392         if self.url:
1393             href = self.html_url % self.url
1394             return '<link rel="stylesheet" href="%s" type="text/css"%s/>' % (href, media)
1395         else:
1396             return '<style type="text/css"%s>%s</style>' % (media, self.with_header())
1397
1398 class SassAsset(StylesheetAsset):
1399     html_url = '%s.css'
1400     rx_indent = re.compile(r'^( +|\t+)', re.M)
1401     indent = None
1402     reindent = '    '
1403
1404     def minify(self):
1405         return self.with_header()
1406
1407     def to_html(self):
1408         if self.url:
1409             ira = self.registry['ir.attachment']
1410             url = self.html_url % self.url
1411             domain = [('type', '=', 'binary'), ('url', '=', self.url)]
1412             ira_id = ira.search(self.cr, self.uid, domain, context=self.context)
1413             if ira_id:
1414                 # TODO: update only if needed
1415                 ira.write(self.cr, openerp.SUPERUSER_ID, [ira_id], {'datas': self.content}, context=self.context)
1416             else:
1417                 ira.create(self.cr, openerp.SUPERUSER_ID, dict(
1418                     datas=self.content.encode('utf8').encode('base64'),
1419                     mimetype='text/css',
1420                     type='binary',
1421                     name=url,
1422                     url=url,
1423                 ), context=self.context)
1424         return super(SassAsset, self).to_html()
1425
1426     def get_source(self):
1427         content = textwrap.dedent(self.inline or self._fetch_content())
1428
1429         def fix_indent(m):
1430             ind = m.group()
1431             if self.indent is None:
1432                 self.indent = ind
1433                 if self.indent == self.reindent:
1434                     # Don't reindent the file if identation is the final one (reindent)
1435                     raise StopIteration()
1436             return ind.replace(self.indent, self.reindent)
1437
1438         try:
1439             content = self.rx_indent.sub(fix_indent, content)
1440         except StopIteration:
1441             pass
1442         return "/*! %s */\n%s" % (self.id, content)
1443
1444 def rjsmin(script):
1445     """ Minify js with a clever regex.
1446     Taken from http://opensource.perlig.de/rjsmin
1447     Apache License, Version 2.0 """
1448     def subber(match):
1449         """ Substitution callback """
1450         groups = match.groups()
1451         return (
1452             groups[0] or
1453             groups[1] or
1454             groups[2] or
1455             groups[3] or
1456             (groups[4] and '\n') or
1457             (groups[5] and ' ') or
1458             (groups[6] and ' ') or
1459             (groups[7] and ' ') or
1460             ''
1461         )
1462
1463     result = re.sub(
1464         r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
1465         r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
1466         r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
1467         r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
1468         r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
1469         r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
1470         r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
1471         r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
1472         r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
1473         r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
1474         r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
1475         r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
1476         r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
1477         r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
1478         r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
1479         r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
1480         r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
1481         r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
1482         r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
1483         r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
1484         r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
1485     ).strip()
1486     return result
1487
1488 # vim:et: