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