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