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