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