1 # -*- coding: utf-8 -*-
15 from subprocess import Popen, PIPE
16 from urlparse import urlparse
21 from lxml import etree, html
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 _
34 _logger = logging.getLogger(__name__)
36 #--------------------------------------------------------------------
37 # QWeb template engine
38 #--------------------------------------------------------------------
39 class QWebException(Exception):
40 def __init__(self, message, **kw):
41 Exception.__init__(self, message)
44 if 'node' not in self.qweb:
46 return etree.tostring(self.qweb['node'], pretty_print=True)
48 class QWebTemplateNotFound(QWebException):
51 def raise_qweb_exception(etype=None, **kw):
54 orig_type, original, tb = sys.exc_info()
56 raise etype, original, tb
58 for k, v in kw.items():
60 # Will use `raise foo from bar` in python 3 and rename cause to __cause__
61 e.qweb['cause'] = original
64 class QWebContext(dict):
65 def __init__(self, cr, uid, data, loader=None, templates=None, context=None):
69 self.templates = templates or {}
70 self.context = context
72 super(QWebContext, self).__init__(dic)
73 self['defined'] = lambda key: key in self
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)
83 return QWebContext(self.cr, self.uid, dict.copy(self),
85 templates=self.templates,
91 class QWeb(orm.AbstractModel):
92 """QWeb Xml templating engine
94 The templating engine use a very simple syntax based "magic" xml
95 attributes, to produce textual output (even non-xml).
97 The core magic attributes are:
100 t-if t-foreach t-call
103 t-att t-raw t-esc t-trim
105 assignation attribute:
108 QWeb can be extended like any OpenERP model and new attributes can be
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.
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.
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(
130 # jinja-style pattern
134 def __init__(self, pool, cr):
135 super(QWeb, self).__init__(pool, cr)
137 self._render_tag = self.prefixed_methods('render_tag_')
138 self._render_att = self.prefixed_methods('render_att_')
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
148 n_prefix = len(prefix)
150 (name[n_prefix:].replace('_', '-'), getattr(type(self), name))
151 for name in dir(self)
152 if name.startswith(prefix)
155 def register_tag(self, tag, func):
156 self._render_tag[tag] = func
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
162 def load_document(self, document, res_id, qwebcontext):
164 Loads an XML document and installs any contained template in the engine
166 if hasattr(document, 'documentElement'):
168 elif document.startswith("<?xml"):
169 dom = etree.fromstring(document)
171 dom = etree.parse(document)
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)
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:
185 xml_doc = qwebcontext.loader(name)
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)
190 if name in qwebcontext.templates:
191 return qwebcontext.templates[name]
193 raise QWebTemplateNotFound("Template %r not found" % name, template=origin_template)
195 def eval(self, expr, qwebcontext):
197 return qwebcontext.safe_eval(expr)
199 template = qwebcontext.get('__template__')
200 raise_qweb_exception(message="Could not evaluate expression %r" % expr, expression=expr, template=template)
202 def eval_object(self, expr, qwebcontext):
203 return self.eval(expr, qwebcontext)
205 def eval_str(self, expr, qwebcontext):
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:
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),
225 return str(expr % qwebcontext)
227 template = qwebcontext.get('__template__')
228 raise_qweb_exception(message="Format error for expression %r" % expr, expression=expr, template=template)
230 def eval_bool(self, expr, qwebcontext):
231 return int(bool(self.eval(expr, qwebcontext)))
233 def render(self, cr, uid, id_or_xml_id, qwebcontext=None, loader=None, context=None):
234 if qwebcontext is None:
237 if not isinstance(qwebcontext, QWebContext):
238 qwebcontext = QWebContext(cr, uid, qwebcontext, loader=loader, context=context)
240 qwebcontext['__template__'] = id_or_xml_id
241 stack = qwebcontext.get('__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)
249 def render_node(self, element, qwebcontext):
250 generated_attributes = ""
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
260 if qwebcontext.get('editable') and not qwebcontext.get('editable_no_editor'):
261 errmsg = _("Editor disabled because some content can not be seen by a user who does not belong to the groups %s")
262 raise openerp.http.Retry(
263 _("User does not belong to groups %s") % attribute_value, {
264 'editable_no_editor': errmsg % attribute_value
268 attribute_value = attribute_value.encode("utf8")
270 if attribute_name.startswith("t-"):
271 for attribute in self._render_att:
272 if attribute_name[2:].startswith(attribute):
273 att, val = self._render_att[attribute](self, element, attribute_name, attribute_value, qwebcontext)
274 generated_attributes += val and ' %s="%s"' % (att, escape(val)) or " "
277 if attribute_name[2:] in self._render_tag:
278 t_render = attribute_name[2:]
279 template_attributes[attribute_name[2:]] = attribute_value
281 generated_attributes += ' %s="%s"' % (attribute_name, escape(attribute_value))
283 if 'debug' in template_attributes:
284 debugger = template_attributes.get('debug', 'pdb')
285 __import__(debugger).set_trace() # pdb, ipdb, pudb, ...
287 result = self._render_tag[t_render](self, element, template_attributes, generated_attributes, qwebcontext)
289 result = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
292 result += element.tail
294 if isinstance(result, unicode):
295 return result.encode('utf-8')
298 def render_element(self, element, template_attributes, generated_attributes, qwebcontext, inner=None):
300 # template_attributes: t-* attributes
301 # generated_attributes: generated attributes
302 # qwebcontext: values
303 # inner: optional innerXml
307 g_inner = [] if element.text is None else [element.text]
308 for current_node in element.iterchildren(tag=etree.Element):
310 g_inner.append(self.render_node(current_node, qwebcontext))
311 except (QWebException, openerp.http.Retry):
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)
322 inner = inner.lstrip()
323 elif trim == 'right':
324 inner = inner.rstrip()
326 inner = inner.strip()
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)
335 return "<%s%s/>" % (name, generated_attributes)
338 def render_att_att(self, element, attribute_name, attribute_value, qwebcontext):
339 if attribute_name.startswith("t-attf-"):
340 att, val = attribute_name[7:], self.eval_format(attribute_value, qwebcontext)
341 elif attribute_name.startswith("t-att-"):
342 att, val = attribute_name[6:], self.eval(attribute_value, qwebcontext)
344 att, val = self.eval_object(attribute_value, qwebcontext)
345 if val and not isinstance(val, str):
346 val = unicode(val).encode("utf8")
350 def render_tag_raw(self, element, template_attributes, generated_attributes, qwebcontext):
351 inner = self.eval_str(template_attributes["raw"], qwebcontext)
352 return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
354 def render_tag_esc(self, element, template_attributes, generated_attributes, qwebcontext):
355 options = json.loads(template_attributes.get('esc-options') or '{}')
356 widget = self.get_widget_for(options.get('widget', ''))
357 inner = widget.format(template_attributes['esc'], options, qwebcontext)
358 return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
360 def render_tag_foreach(self, element, template_attributes, generated_attributes, qwebcontext):
361 expr = template_attributes["foreach"]
362 enum = self.eval_object(expr, qwebcontext)
364 template = qwebcontext.get('__template__')
365 raise QWebException("foreach enumerator %r is not defined while rendering template %r" % (expr, template), template=template)
367 varname = template_attributes['as'].replace('.', '_')
368 copy_qwebcontext = qwebcontext.copy()
370 if isinstance(enum, collections.Sized):
372 copy_qwebcontext["%s_size" % varname] = size
373 copy_qwebcontext["%s_all" % varname] = enum
375 for index, item in enumerate(enum):
376 copy_qwebcontext.update({
378 '%s_value' % varname: item,
379 '%s_index' % varname: index,
380 '%s_first' % varname: index == 0,
381 '%s_last' % varname: index + 1 == size,
384 copy_qwebcontext.update({
385 '%s_parity' % varname: 'odd',
386 '%s_even' % varname: False,
387 '%s_odd' % varname: True,
390 copy_qwebcontext.update({
391 '%s_parity' % varname: 'even',
392 '%s_even' % varname: True,
393 '%s_odd' % varname: False,
395 ru.append(self.render_element(element, template_attributes, generated_attributes, copy_qwebcontext))
398 def render_tag_if(self, element, template_attributes, generated_attributes, qwebcontext):
399 if self.eval_bool(template_attributes["if"], qwebcontext):
400 return self.render_element(element, template_attributes, generated_attributes, qwebcontext)
403 def render_tag_call(self, element, template_attributes, generated_attributes, qwebcontext):
404 d = qwebcontext.copy()
405 d[0] = self.render_element(element, template_attributes, generated_attributes, d)
406 cr = d.get('request') and d['request'].cr or None
407 uid = d.get('request') and d['request'].uid or None
409 template = self.eval_format(template_attributes["call"], d)
411 template = int(template)
414 return self.render(cr, uid, template, d)
416 def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
417 """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
419 # An asset bundle is rendered in two differents contexts (when genereting html and
420 # when generating the bundle itself) so they must be qwebcontext free
421 # even '0' variable is forbidden
422 template = qwebcontext.get('__template__')
423 raise QWebException("t-call-assets cannot contain children nodes", template=template)
424 xmlid = template_attributes['call-assets']
425 cr, uid, context = [getattr(qwebcontext, attr) for attr in ('cr', 'uid', 'context')]
426 bundle = AssetsBundle(xmlid, cr=cr, uid=uid, context=context, registry=self.pool)
427 css = self.get_attr_bool(template_attributes.get('css'), default=True)
428 js = self.get_attr_bool(template_attributes.get('js'), default=True)
429 return bundle.to_html(css=css, js=js, debug=bool(qwebcontext.get('debug')))
431 def render_tag_set(self, element, template_attributes, generated_attributes, qwebcontext):
432 if "value" in template_attributes:
433 qwebcontext[template_attributes["set"]] = self.eval_object(template_attributes["value"], qwebcontext)
434 elif "valuef" in template_attributes:
435 qwebcontext[template_attributes["set"]] = self.eval_format(template_attributes["valuef"], qwebcontext)
437 qwebcontext[template_attributes["set"]] = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
440 def render_tag_field(self, element, template_attributes, generated_attributes, qwebcontext):
441 """ eg: <span t-record="browse_record(res.partner, 1)" t-field="phone">+1 555 555 8069</span>"""
442 node_name = element.tag
443 assert node_name not in ("table", "tbody", "thead", "tfoot", "tr", "td",
444 "li", "ul", "ol", "dl", "dt", "dd"),\
445 "RTE widgets do not work correctly on %r elements" % node_name
446 assert node_name != 't',\
447 "t-field can not be used on a t element, provide an actual HTML node"
449 record, field_name = template_attributes["field"].rsplit('.', 1)
450 record = self.eval_object(record, qwebcontext)
452 column = record._all_columns[field_name].column
453 options = json.loads(template_attributes.get('field-options') or '{}')
454 field_type = get_field_type(column, options)
456 converter = self.get_converter_for(field_type)
458 return converter.to_html(qwebcontext.cr, qwebcontext.uid, field_name, record, options,
459 element, template_attributes, generated_attributes, qwebcontext, context=qwebcontext.context)
461 def get_converter_for(self, field_type):
462 return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
464 def get_widget_for(self, widget):
465 return self.pool.get('ir.qweb.widget.' + widget, self.pool['ir.qweb.widget'])
467 def get_attr_bool(self, attr, default=False):
470 if attr in ('false', '0'):
472 elif attr in ('true', '1'):
476 #--------------------------------------------------------------------
477 # QWeb Fields converters
478 #--------------------------------------------------------------------
480 class FieldConverter(osv.AbstractModel):
481 """ Used to convert a t-field specification into an output HTML field.
483 :meth:`~.to_html` is the entry point of this conversion from QWeb, it:
485 * converts the record value to html using :meth:`~.record_to_html`
486 * generates the metadata attributes (``data-oe-``) to set on the root
488 * generates the root result node itself through :meth:`~.render_element`
490 _name = 'ir.qweb.field'
492 def attributes(self, cr, uid, field_name, record, options,
493 source_element, g_att, t_att, qweb_context,
496 Generates the metadata attributes (prefixed by ``data-oe-`` for the
497 root node of the field conversion. Attribute values are escaped by the
500 The default attributes are:
502 * ``model``, the name of the record's model
503 * ``id`` the id of the record to which the field belongs
504 * ``field`` the name of the converted field
505 * ``type`` the logical field type (widget, may not match the column's
506 ``type``, may not be any _column subclass name)
507 * ``translate``, a boolean flag (``0`` or ``1``) denoting whether the
508 column is translatable
509 * ``expression``, the original expression
511 :returns: iterable of (attribute name, attribute value) pairs.
513 column = record._all_columns[field_name].column
514 field_type = get_field_type(column, options)
516 ('data-oe-model', record._name),
517 ('data-oe-id', record.id),
518 ('data-oe-field', field_name),
519 ('data-oe-type', field_type),
520 ('data-oe-expression', t_att['field']),
523 def value_to_html(self, cr, uid, value, column, options=None, context=None):
524 """ Converts a single value to its HTML version/output
526 if not value: return ''
529 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
530 """ Converts the specified field of the browse_record ``record`` to
533 return self.value_to_html(
534 cr, uid, record[field_name], column, options=options, context=context)
536 def to_html(self, cr, uid, field_name, record, options,
537 source_element, t_att, g_att, qweb_context, context=None):
538 """ Converts a ``t-field`` to its HTML output. A ``t-field`` may be
539 extended by a ``t-field-options``, which is a JSON-serialized mapping
540 of configuration values.
542 A default configuration key is ``widget`` which can override the
543 field's own ``_type``.
546 content = self.record_to_html(
547 cr, uid, field_name, record,
548 record._all_columns[field_name].column,
549 options, context=context)
550 if options.get('html-escape', True):
551 content = escape(content)
552 elif hasattr(content, '__html__'):
553 content = content.__html__()
555 _logger.warning("Could not get field %s for model %s",
556 field_name, record._name, exc_info=True)
559 if context and context.get('inherit_branding'):
560 # add branding attributes
562 ' %s="%s"' % (name, escape(value))
563 for name, value in self.attributes(
564 cr, uid, field_name, record, options,
565 source_element, g_att, t_att, qweb_context)
568 return self.render_element(cr, uid, source_element, t_att, g_att,
569 qweb_context, content)
571 def qweb_object(self):
572 return self.pool['ir.qweb']
574 def render_element(self, cr, uid, source_element, t_att, g_att,
575 qweb_context, content):
576 """ Final rendering hook, by default just calls ir.qweb's ``render_element``
578 return self.qweb_object().render_element(
579 source_element, t_att, g_att, qweb_context, content or '')
581 def user_lang(self, cr, uid, context):
583 Fetches the res.lang object corresponding to the language code stored
584 in the user's context. Fallbacks to en_US if no lang is present in the
585 context *or the language code is not valid*.
587 :returns: res.lang browse_record
589 if context is None: context = {}
591 lang_code = context.get('lang') or 'en_US'
592 Lang = self.pool['res.lang']
594 lang_ids = Lang.search(cr, uid, [('code', '=', lang_code)], context=context) \
595 or Lang.search(cr, uid, [('code', '=', 'en_US')], context=context)
597 return Lang.browse(cr, uid, lang_ids[0], context=context)
599 class FloatConverter(osv.AbstractModel):
600 _name = 'ir.qweb.field.float'
601 _inherit = 'ir.qweb.field'
603 def precision(self, cr, uid, column, options=None, context=None):
604 _, precision = column.digits or (None, None)
607 def value_to_html(self, cr, uid, value, column, options=None, context=None):
610 precision = self.precision(cr, uid, column, options=options, context=context)
611 fmt = '%f' if precision is None else '%.{precision}f'
613 lang_code = context.get('lang') or 'en_US'
614 lang = self.pool['res.lang']
615 formatted = lang.format(cr, uid, [lang_code], fmt.format(precision=precision), value, grouping=True)
617 # %f does not strip trailing zeroes. %g does but its precision causes
618 # it to switch to scientific notation starting at a million *and* to
619 # strip decimals. So use %f and if no precision was specified manually
621 if precision is None:
622 formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted)
625 class DateConverter(osv.AbstractModel):
626 _name = 'ir.qweb.field.date'
627 _inherit = 'ir.qweb.field'
629 def value_to_html(self, cr, uid, value, column, options=None, context=None):
630 if not value: return ''
631 lang = self.user_lang(cr, uid, context=context)
632 locale = babel.Locale.parse(lang.code)
634 if isinstance(value, basestring):
635 value = datetime.datetime.strptime(
636 value, openerp.tools.DEFAULT_SERVER_DATE_FORMAT)
638 if options and 'format' in options:
639 pattern = options['format']
641 strftime_pattern = lang.date_format
642 pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
644 return babel.dates.format_date(
645 value, format=pattern,
648 class DateTimeConverter(osv.AbstractModel):
649 _name = 'ir.qweb.field.datetime'
650 _inherit = 'ir.qweb.field'
652 def value_to_html(self, cr, uid, value, column, options=None, context=None):
653 if not value: return ''
654 lang = self.user_lang(cr, uid, context=context)
655 locale = babel.Locale.parse(lang.code)
657 if isinstance(value, basestring):
658 value = datetime.datetime.strptime(
659 value, openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT)
660 value = fields.datetime.context_timestamp(
661 cr, uid, timestamp=value, context=context)
663 if options and 'format' in options:
664 pattern = options['format']
666 strftime_pattern = (u"%s %s" % (lang.date_format, lang.time_format))
667 pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
669 if options and options.get('hide_seconds'):
670 pattern = pattern.replace(":ss", "").replace(":s", "")
672 return babel.dates.format_datetime(value, format=pattern, locale=locale)
674 class TextConverter(osv.AbstractModel):
675 _name = 'ir.qweb.field.text'
676 _inherit = 'ir.qweb.field'
678 def value_to_html(self, cr, uid, value, column, options=None, context=None):
680 Escapes the value and converts newlines to br. This is bullshit.
682 if not value: return ''
684 return nl2br(value, options=options)
686 class SelectionConverter(osv.AbstractModel):
687 _name = 'ir.qweb.field.selection'
688 _inherit = 'ir.qweb.field'
690 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
691 value = record[field_name]
692 if not value: return ''
693 selection = dict(fields.selection.reify(
694 cr, uid, record._model, column))
695 return self.value_to_html(
696 cr, uid, selection[value], column, options=options)
698 class ManyToOneConverter(osv.AbstractModel):
699 _name = 'ir.qweb.field.many2one'
700 _inherit = 'ir.qweb.field'
702 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
703 [read] = record.read([field_name])
704 if not read[field_name]: return ''
705 _, value = read[field_name]
706 return nl2br(value, options=options)
708 class HTMLConverter(osv.AbstractModel):
709 _name = 'ir.qweb.field.html'
710 _inherit = 'ir.qweb.field'
712 def value_to_html(self, cr, uid, value, column, options=None, context=None):
713 return HTMLSafe(value or '')
715 class ImageConverter(osv.AbstractModel):
716 """ ``image`` widget rendering, inserts a data:uri-using image tag in the
717 document. May be overridden by e.g. the website module to generate links
720 .. todo:: what happens if different output need different converters? e.g.
721 reports may need embedded images or FS links whereas website
724 _name = 'ir.qweb.field.image'
725 _inherit = 'ir.qweb.field'
727 def value_to_html(self, cr, uid, value, column, options=None, context=None):
729 image = Image.open(cStringIO.StringIO(value.decode('base64')))
732 raise ValueError("Non-image binary fields can not be converted to HTML")
733 except: # image.verify() throws "suitable exceptions", I have no idea what they are
734 raise ValueError("Invalid image content")
736 return HTMLSafe('<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value))
738 class MonetaryConverter(osv.AbstractModel):
739 """ ``monetary`` converter, has a mandatory option
740 ``display_currency``.
742 The currency is used for formatting *and rounding* of the float value. It
743 is assumed that the linked res_currency has a non-empty rounding value and
744 res.currency's ``round`` method is used to perform rounding.
746 .. note:: the monetary converter internally adds the qweb context to its
747 options mapping, so that the context is available to callees.
748 It's set under the ``_qweb_context`` key.
750 _name = 'ir.qweb.field.monetary'
751 _inherit = 'ir.qweb.field'
753 def to_html(self, cr, uid, field_name, record, options,
754 source_element, t_att, g_att, qweb_context, context=None):
755 options['_qweb_context'] = qweb_context
756 return super(MonetaryConverter, self).to_html(
757 cr, uid, field_name, record, options,
758 source_element, t_att, g_att, qweb_context, context=context)
760 def record_to_html(self, cr, uid, field_name, record, column, options, context=None):
763 Currency = self.pool['res.currency']
764 display_currency = self.display_currency(cr, uid, options['display_currency'], options)
766 # lang.format mandates a sprintf-style format. These formats are non-
767 # minimal (they have a default fixed precision instead), and
768 # lang.format will not set one by default. currency.round will not
769 # provide one either. So we need to generate a precision value
770 # (integer > 0) from the currency's rounding (a float generally < 1.0).
772 # The log10 of the rounding should be the number of digits involved if
773 # negative, if positive clamp to 0 digits and call it a day.
774 # nb: int() ~ floor(), we want nearest rounding instead
775 precision = int(round(math.log10(display_currency.rounding)))
776 fmt = "%.{0}f".format(-precision if precision < 0 else 0)
778 from_amount = record[field_name]
780 if options.get('from_currency'):
781 from_currency = self.display_currency(cr, uid, options['from_currency'], options)
782 from_amount = Currency.compute(cr, uid, from_currency.id, display_currency.id, from_amount)
784 lang_code = context.get('lang') or 'en_US'
785 lang = self.pool['res.lang']
786 formatted_amount = lang.format(cr, uid, [lang_code],
787 fmt, Currency.round(cr, uid, display_currency, from_amount),
788 grouping=True, monetary=True)
791 if display_currency.position == 'before':
796 return HTMLSafe(u'{pre}<span class="oe_currency_value">{0}</span>{post}'.format(
800 symbol=display_currency.symbol,
803 def display_currency(self, cr, uid, currency, options):
804 return self.qweb_object().eval_object(
805 currency, options['_qweb_context'])
808 ('year', 3600 * 24 * 365),
809 ('month', 3600 * 24 * 30),
810 ('week', 3600 * 24 * 7),
816 class DurationConverter(osv.AbstractModel):
817 """ ``duration`` converter, to display integral or fractional values as
818 human-readable time spans (e.g. 1.5 as "1 hour 30 minutes").
820 Can be used on any numerical field.
822 Has a mandatory option ``unit`` which can be one of ``second``, ``minute``,
823 ``hour``, ``day``, ``week`` or ``year``, used to interpret the numerical
824 field value before converting it.
826 Sub-second values will be ignored.
828 _name = 'ir.qweb.field.duration'
829 _inherit = 'ir.qweb.field'
831 def value_to_html(self, cr, uid, value, column, options=None, context=None):
832 units = dict(TIMEDELTA_UNITS)
834 raise ValueError(_("Durations can't be negative"))
835 if not options or options.get('unit') not in units:
836 raise ValueError(_("A unit must be provided to duration widgets"))
838 locale = babel.Locale.parse(
839 self.user_lang(cr, uid, context=context).code)
840 factor = units[options['unit']]
844 for unit, secs_per_unit in TIMEDELTA_UNITS:
845 v, r = divmod(r, secs_per_unit)
847 section = babel.dates.format_timedelta(
848 v*secs_per_unit, threshold=1, locale=locale)
850 sections.append(section)
851 return ' '.join(sections)
854 class RelativeDatetimeConverter(osv.AbstractModel):
855 _name = 'ir.qweb.field.relative'
856 _inherit = 'ir.qweb.field'
858 def value_to_html(self, cr, uid, value, column, options=None, context=None):
859 parse_format = openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT
860 locale = babel.Locale.parse(
861 self.user_lang(cr, uid, context=context).code)
863 if isinstance(value, basestring):
864 value = datetime.datetime.strptime(value, parse_format)
866 # value should be a naive datetime in UTC. So is fields.datetime.now()
867 reference = datetime.datetime.strptime(column.now(), parse_format)
869 return babel.dates.format_timedelta(
870 value - reference, add_direction=True, locale=locale)
872 class Contact(orm.AbstractModel):
873 _name = 'ir.qweb.field.contact'
874 _inherit = 'ir.qweb.field.many2one'
876 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
879 opf = options.get('fields') or ["name", "address", "phone", "mobile", "fax", "email"]
881 if not getattr(record, field_name):
884 id = getattr(record, field_name).id
885 field_browse = self.pool[column._obj].browse(cr, openerp.SUPERUSER_ID, id, context={"show_address": True})
886 value = field_browse.name_get()[0][1]
889 'name': value.split("\n")[0],
890 'address': escape("\n".join(value.split("\n")[1:])),
891 'phone': field_browse.phone,
892 'mobile': field_browse.mobile,
893 'fax': field_browse.fax,
894 'city': field_browse.city,
895 'country_id': field_browse.country_id.display_name,
896 'website': field_browse.website,
897 'email': field_browse.email,
899 'object': field_browse,
903 html = self.pool["ir.ui.view"].render(cr, uid, "base.contact", val, engine='ir.qweb', context=context).decode('utf8')
905 return HTMLSafe(html)
907 class QwebView(orm.AbstractModel):
908 _name = 'ir.qweb.field.qweb'
909 _inherit = 'ir.qweb.field.many2one'
911 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
912 if not getattr(record, field_name):
915 view = getattr(record, field_name)
917 if view._model._name != "ir.ui.view":
918 _logger.warning("%s.%s must be a 'ir.ui.view' model." % (record, field_name))
921 ctx = (context or {}).copy()
922 ctx['object'] = record
923 html = view.render(ctx, engine='ir.qweb', context=ctx).decode('utf8')
925 return HTMLSafe(html)
927 class QwebWidget(osv.AbstractModel):
928 _name = 'ir.qweb.widget'
930 def _format(self, inner, options, qwebcontext):
931 return self.pool['ir.qweb'].eval_str(inner, qwebcontext)
933 def format(self, inner, options, qwebcontext):
934 return escape(self._format(inner, options, qwebcontext))
936 class QwebWidgetMonetary(osv.AbstractModel):
937 _name = 'ir.qweb.widget.monetary'
938 _inherit = 'ir.qweb.widget'
940 def _format(self, inner, options, qwebcontext):
941 inner = self.pool['ir.qweb'].eval(inner, qwebcontext)
942 display = self.pool['ir.qweb'].eval_object(options['display_currency'], qwebcontext)
943 precision = int(round(math.log10(display.rounding)))
944 fmt = "%.{0}f".format(-precision if precision < 0 else 0)
945 lang_code = qwebcontext.context.get('lang') or 'en_US'
946 formatted_amount = self.pool['res.lang'].format(
947 qwebcontext.cr, qwebcontext.uid, [lang_code], fmt, inner, grouping=True, monetary=True
950 if display.position == 'before':
955 return u'{pre}{0}{post}'.format(
956 formatted_amount, pre=pre, post=post
957 ).format(symbol=display.symbol,)
959 class HTMLSafe(object):
960 """ HTMLSafe string wrapper, Werkzeug's escape() has special handling for
961 objects with a ``__html__`` methods but AFAIK does not provide any such
964 Wrapping a string in HTML will prevent its escaping
966 __slots__ = ['string']
967 def __init__(self, string):
973 if isinstance(s, unicode):
974 return s.encode('utf-8')
976 def __unicode__(self):
978 if isinstance(s, str):
979 return s.decode('utf-8')
982 def nl2br(string, options=None):
983 """ Converts newlines to HTML linebreaks in ``string``. Automatically
984 escapes content unless options['html-escape'] is set to False, and returns
985 the result wrapped in an HTMLSafe object.
991 if options is None: options = {}
993 if options.get('html-escape', True):
994 string = escape(string)
995 return HTMLSafe(string.replace('\n', '<br>\n'))
997 def get_field_type(column, options):
998 """ Gets a t-field's effective type from the field's column and its options
1000 return options.get('widget', column._type)
1002 class AssetError(Exception):
1004 class AssetNotFound(AssetError):
1007 class AssetsBundle(object):
1008 # Sass installation:
1010 # sudo gem install sass compass bootstrap-sass
1012 # If the following error is encountered:
1013 # 'ERROR: Cannot load compass.'
1015 # sudo gem install compass --pre
1016 cmd_sass = ['sass', '--stdin', '-t', 'compressed', '--unix-newlines', '--compass', '-r', 'bootstrap-sass']
1017 cache = openerp.tools.lru.LRU(32)
1018 rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
1019 rx_sass_import = re.compile("""(@import\s?['"]([^'"]+)['"])""")
1020 rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/")
1022 def __init__(self, xmlid, debug=False, cr=None, uid=None, context=None, registry=None):
1024 self.cr = request.cr if cr is None else cr
1025 self.uid = request.uid if uid is None else uid
1026 self.context = request.context if context is None else context
1027 self.registry = request.registry if registry is None else registry
1028 self.javascripts = []
1029 self.stylesheets = []
1030 self.css_errors = []
1032 self._checksum = None
1034 context = self.context.copy()
1035 context['inherit_branding'] = False
1036 self.html = self.registry['ir.ui.view'].render(self.cr, self.uid, xmlid, context=context)
1040 fragments = html.fragments_fromstring(self.html)
1041 for el in fragments:
1042 if isinstance(el, basestring):
1043 self.remains.append(el)
1044 elif isinstance(el, html.HtmlElement):
1045 src = el.get('src', '')
1046 href = el.get('href', '')
1047 atype = el.get('type')
1048 media = el.get('media')
1049 if el.tag == 'style':
1050 if atype == 'text/sass' or src.endswith('.sass'):
1051 self.stylesheets.append(SassAsset(self, inline=el.text, media=media))
1053 self.stylesheets.append(StylesheetAsset(self, inline=el.text, media=media))
1054 elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href):
1055 if href.endswith('.sass') or atype == 'text/sass':
1056 self.stylesheets.append(SassAsset(self, url=href, media=media))
1058 self.stylesheets.append(StylesheetAsset(self, url=href, media=media))
1059 elif el.tag == 'script' and not src:
1060 self.javascripts.append(JavascriptAsset(self, inline=el.text))
1061 elif el.tag == 'script' and self.can_aggregate(src):
1062 self.javascripts.append(JavascriptAsset(self, url=src))
1064 self.remains.append(html.tostring(el))
1067 self.remains.append(html.tostring(el))
1069 # notYETimplementederror
1070 raise NotImplementedError
1072 def can_aggregate(self, url):
1073 return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js'))
1075 def to_html(self, sep=None, css=True, js=True, debug=False):
1080 if css and self.stylesheets:
1082 for style in self.stylesheets:
1083 response.append(style.to_html())
1085 for jscript in self.javascripts:
1086 response.append(jscript.to_html())
1088 if css and self.stylesheets:
1089 response.append('<link href="/web/css/%s/%s" rel="stylesheet"/>' % (self.xmlid, self.version))
1091 response.append('<script type="text/javascript" src="/web/js/%s/%s"></script>' % (self.xmlid, self.version))
1092 response.extend(self.remains)
1093 return sep + sep.join(response)
1096 def last_modified(self):
1097 """Returns last modified date of linked files"""
1098 return max(itertools.chain(
1099 (asset.last_modified for asset in self.javascripts),
1100 (asset.last_modified for asset in self.stylesheets),
1105 return self.checksum[0:7]
1110 Not really a full checksum.
1111 We compute a SHA1 on the rendered bundle + max linked files last_modified date
1113 check = self.html + str(self.last_modified)
1114 return hashlib.sha1(check).hexdigest()
1117 key = 'js_%s' % self.xmlid
1118 if key in self.cache and self.cache[key][0] != self.version:
1119 # Invalidate cache on version mismach
1121 if key not in self.cache:
1122 content =';\n'.join(asset.minify() for asset in self.javascripts)
1123 self.cache[key] = (self.version, content)
1124 return self.cache[key][1]
1127 key = 'css_%s' % self.xmlid
1128 if key in self.cache and self.cache[key][0] != self.version:
1129 # Invalidate cache on version mismach
1131 if key not in self.cache:
1133 content = '\n'.join(asset.minify() for asset in self.stylesheets)
1136 msg = '\n'.join(self.css_errors)
1137 content += self.css_message(msg.replace('\n', '\\A '))
1139 # move up all @import rules to the top
1142 matches.append(matchobj.group(0))
1145 content = re.sub(self.rx_css_import, push, content)
1147 matches.append(content)
1148 content = u'\n'.join(matches)
1151 self.cache[key] = (self.version, content)
1153 return self.cache[key][1]
1155 def css_message(self, message):
1161 font-family: monospace;
1165 """ % message.replace('"', '\\"')
1167 def compile_sass(self):
1169 Checks if the bundle contains any sass content, then compiles it to css.
1170 Css compilation is done at the bundle level and not in the assets
1171 because they are potentially interdependant.
1173 sass = [asset for asset in self.stylesheets if isinstance(asset, SassAsset)]
1176 source = '\n'.join([asset.get_source() for asset in sass])
1178 # move up all @import rules to the top and exclude file imports
1181 ref = matchobj.group(2)
1182 line = '@import "%s"' % ref
1183 if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')):
1184 imports.append(line)
1186 source = re.sub(self.rx_sass_import, push, source)
1187 imports.append(source)
1188 source = u'\n'.join(imports)
1191 compiler = Popen(self.cmd_sass, stdin=PIPE, stdout=PIPE, stderr=PIPE)
1193 msg = "Could not find 'sass' program needed to compile sass/scss files"
1195 self.css_errors.append(msg)
1197 result = compiler.communicate(input=source.encode('utf-8'))
1198 if compiler.returncode:
1199 error = self.get_sass_error(''.join(result), source=source)
1200 _logger.warning(error)
1201 self.css_errors.append(error)
1203 compiled = result[0].strip().decode('utf8')
1204 fragments = self.rx_css_split.split(compiled)[1:]
1206 asset_id = fragments.pop(0)
1207 asset = next(asset for asset in sass if asset.id == asset_id)
1208 asset._content = fragments.pop(0)
1210 def get_sass_error(self, stderr, source=None):
1211 # TODO: try to find out which asset the error belongs to
1212 error = stderr.split('Load paths')[0].replace(' Use --trace for backtrace.', '')
1213 error += "This error occured while compiling the bundle '%s' containing:" % self.xmlid
1214 for asset in self.stylesheets:
1215 if isinstance(asset, SassAsset):
1216 error += '\n - %s' % (asset.url if asset.url else '<inline sass>')
1219 class WebAsset(object):
1222 def __init__(self, bundle, inline=None, url=None):
1223 self.id = str(uuid.uuid4())
1224 self.bundle = bundle
1225 self.inline = inline
1228 self.uid = bundle.uid
1229 self.registry = bundle.registry
1230 self.context = bundle.context
1231 self._content = None
1232 self._filename = None
1233 self._ir_attach = None
1234 name = '<inline asset>' if inline else url
1235 self.name = "%s defined in bundle '%s'" % (name, bundle.xmlid)
1236 if not inline and not url:
1237 raise Exception("An asset should either be inlined or url linked")
1240 if not (self.inline or self._filename or self._ir_attach):
1241 addon = filter(None, self.url.split('/'))[0]
1243 # Test url against modules static assets
1244 mpath = openerp.http.addons_manifest[addon]['addons_path']
1245 self._filename = mpath + self.url.replace('/', os.path.sep)
1248 # Test url against ir.attachments
1249 fields = ['__last_update', 'datas', 'mimetype']
1250 domain = [('type', '=', 'binary'), ('url', '=', self.url)]
1251 ira = self.registry['ir.attachment']
1252 attach = ira.search_read(self.cr, self.uid, domain, fields, context=self.context)
1253 self._ir_attach = attach[0]
1255 raise AssetNotFound("Could not find %s" % self.name)
1258 raise NotImplementedError()
1261 def last_modified(self):
1265 return datetime.datetime.fromtimestamp(os.path.getmtime(self._filename))
1266 elif self._ir_attach:
1267 server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
1268 last_update = self._ir_attach['__last_update']
1270 return datetime.datetime.strptime(last_update, server_format + '.%f')
1272 return datetime.datetime.strptime(last_update, server_format)
1275 return datetime.datetime(1970, 1, 1)
1279 if not self._content:
1280 self._content = self.inline or self._fetch_content()
1281 return self._content
1283 def _fetch_content(self):
1284 """ Fetch content from file or database"""
1288 with open(self._filename, 'rb') as fp:
1289 return fp.read().decode('utf-8')
1291 return self._ir_attach['datas'].decode('base64')
1292 except UnicodeDecodeError:
1293 raise AssetError('%s is not utf-8 encoded.' % self.name)
1295 raise AssetNotFound('File %s does not exist.' % self.name)
1297 raise AssetError('Could not get content for %s.' % self.name)
1302 def with_header(self, content=None):
1304 content = self.content
1305 return '\n/* %s */\n%s' % (self.name, content)
1307 class JavascriptAsset(WebAsset):
1309 return self.with_header(rjsmin(self.content))
1311 def _fetch_content(self):
1313 return super(JavascriptAsset, self)._fetch_content()
1314 except AssetError, e:
1315 return "console.error(%s);" % json.dumps(e.message)
1319 return '<script type="text/javascript" src="%s"></script>' % (self.html_url % self.url)
1321 return '<script type="text/javascript" charset="utf-8">%s</script>' % self.with_header()
1323 class StylesheetAsset(WebAsset):
1324 rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
1325 rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
1326 rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
1327 rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
1329 def __init__(self, *args, **kw):
1330 self.media = kw.pop('media', None)
1331 super(StylesheetAsset, self).__init__(*args, **kw)
1335 content = super(StylesheetAsset, self).content
1337 content = '@media %s { %s }' % (self.media, content)
1340 def _fetch_content(self):
1342 content = super(StylesheetAsset, self)._fetch_content()
1343 web_dir = os.path.dirname(self.url)
1345 content = self.rx_import.sub(
1346 r"""@import \1%s/""" % (web_dir,),
1350 content = self.rx_url.sub(
1351 r"url(\1%s/" % (web_dir,),
1355 # remove charset declarations, we only support utf-8
1356 content = self.rx_charset.sub('', content)
1357 except AssetError, e:
1358 self.bundle.css_errors.append(e.message)
1363 # remove existing sourcemaps, make no sense after re-mini
1364 content = self.rx_sourceMap.sub('', self.content)
1366 content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
1368 content = re.sub(r'\s+', ' ', content)
1369 content = re.sub(r' *([{}]) *', r'\1', content)
1370 return self.with_header(content)
1373 media = (' media="%s"' % werkzeug.utils.escape(self.media)) if self.media else ''
1375 href = self.html_url % self.url
1376 return '<link rel="stylesheet" href="%s" type="text/css"%s/>' % (href, media)
1378 return '<style type="text/css"%s>%s</style>' % (media, self.with_header())
1380 class SassAsset(StylesheetAsset):
1382 rx_indent = re.compile(r'^( +|\t+)', re.M)
1387 return self.with_header()
1391 ira = self.registry['ir.attachment']
1392 url = self.html_url % self.url
1393 domain = [('type', '=', 'binary'), ('url', '=', self.url)]
1394 ira_id = ira.search(self.cr, self.uid, domain, context=self.context)
1396 # TODO: update only if needed
1397 ira.write(self.cr, openerp.SUPERUSER_ID, [ira_id], {'datas': self.content}, context=self.context)
1399 ira.create(self.cr, openerp.SUPERUSER_ID, dict(
1400 datas=self.content.encode('utf8').encode('base64'),
1401 mimetype='text/css',
1405 ), context=self.context)
1406 return super(SassAsset, self).to_html()
1408 def get_source(self):
1409 content = textwrap.dedent(self.inline or self._fetch_content())
1413 if self.indent is None:
1415 if self.indent == self.reindent:
1416 # Don't reindent the file if identation is the final one (reindent)
1417 raise StopIteration()
1418 return ind.replace(self.indent, self.reindent)
1421 content = self.rx_indent.sub(fix_indent, content)
1422 except StopIteration:
1424 return "/*! %s */\n%s" % (self.id, content)
1427 """ Minify js with a clever regex.
1428 Taken from http://opensource.perlig.de/rjsmin
1429 Apache License, Version 2.0 """
1431 """ Substitution callback """
1432 groups = match.groups()
1438 (groups[4] and '\n') or
1439 (groups[5] and ' ') or
1440 (groups[6] and ' ') or
1441 (groups[7] and ' ') or
1446 r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
1447 r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
1448 r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
1449 r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
1450 r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
1451 r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
1452 r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
1453 r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
1454 r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
1455 r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
1456 r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
1457 r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
1458 r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
1459 r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
1460 r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
1461 r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
1462 r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
1463 r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
1464 r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
1465 r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
1466 r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script