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