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