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