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