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