1 # -*- coding: utf-8 -*-
12 import xml # FIXME use lxml and etree
15 from urlparse import urlparse
24 import openerp.tools.func
25 import openerp.tools.lru
26 from openerp.tools.safe_eval import safe_eval as eval
27 from openerp.osv import osv, orm, fields
28 from openerp.tools.translate import _
30 _logger = logging.getLogger(__name__)
32 #--------------------------------------------------------------------
33 # QWeb template engine
34 #--------------------------------------------------------------------
35 class QWebException(Exception):
36 def __init__(self, message, **kw):
37 Exception.__init__(self, message)
40 class QWebTemplateNotFound(QWebException):
43 def raise_qweb_exception(etype=None, **kw):
46 orig_type, original, tb = sys.exc_info()
48 raise etype, original, tb
50 for k, v in kw.items():
52 # Will use `raise foo from bar` in python 3 and rename cause to __cause__
53 e.qweb['cause'] = original
56 class QWebContext(dict):
57 def __init__(self, cr, uid, data, loader=None, templates=None, context=None):
61 self.templates = templates or {}
62 self.context = context
64 super(QWebContext, self).__init__(dic)
65 self['defined'] = lambda key: key in self
67 def safe_eval(self, expr):
68 locals_dict = collections.defaultdict(lambda: None)
69 locals_dict.update(self)
70 locals_dict.pop('cr', None)
71 locals_dict.pop('loader', None)
72 return eval(expr, None, locals_dict, nocopy=True, locals_builtins=True)
75 return QWebContext(self.cr, self.uid, dict.copy(self),
77 templates=self.templates,
83 class QWeb(orm.AbstractModel):
84 """QWeb Xml templating engine
86 The templating engine use a very simple syntax based "magic" xml
87 attributes, to produce textual output (even non-xml).
89 The core magic attributes are:
95 t-att t-raw t-esc t-trim
97 assignation attribute:
100 QWeb can be extended like any OpenERP model and new attributes can be
103 If you need to customize t-fields rendering, subclass the ir.qweb.field
104 model (and its sub-models) then override :meth:`~.get_converter_for` to
105 fetch the right field converters for your qweb model.
107 Beware that if you need extensions or alterations which could be
108 incompatible with other subsystems, you should create a local object
109 inheriting from ``ir.qweb`` and customize that.
115 _void_elements = frozenset([
116 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
117 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'])
118 _format_regex = re.compile(
123 # jinja-style pattern
127 def __init__(self, pool, cr):
128 super(QWeb, self).__init__(pool, cr)
130 self._render_tag = self.prefixed_methods('render_tag_')
131 self._render_att = self.prefixed_methods('render_att_')
133 def prefixed_methods(self, prefix):
134 """ Extracts all methods prefixed by ``prefix``, and returns a mapping
135 of (t-name, method) where the t-name is the method name with prefix
136 removed and underscore converted to dashes
141 n_prefix = len(prefix)
143 (name[n_prefix:].replace('_', '-'), getattr(type(self), name))
144 for name in dir(self)
145 if name.startswith(prefix)
148 def register_tag(self, tag, func):
149 self._render_tag[tag] = func
151 def add_template(self, qwebcontext, name, node):
152 """Add a parsed template in the context. Used to preprocess templates."""
153 qwebcontext.templates[name] = node
155 def load_document(self, document, res_id, qwebcontext):
157 Loads an XML document and installs any contained template in the engine
159 if hasattr(document, 'documentElement'):
161 elif document.startswith("<?xml"):
162 dom = xml.dom.minidom.parseString(document)
164 dom = xml.dom.minidom.parse(document)
166 for node in dom.documentElement.childNodes:
167 if node.nodeType == self.node.ELEMENT_NODE:
168 if node.getAttribute('t-name'):
169 name = str(node.getAttribute("t-name"))
170 self.add_template(qwebcontext, name, node)
171 if res_id and node.tagName == "t":
172 self.add_template(qwebcontext, res_id, node)
175 def get_template(self, name, qwebcontext):
176 origin_template = qwebcontext.get('__caller__') or qwebcontext['__stack__'][0]
177 if qwebcontext.loader and name not in qwebcontext.templates:
179 xml_doc = qwebcontext.loader(name)
181 raise_qweb_exception(QWebTemplateNotFound, message="Loader could not find template %r" % name, template=origin_template)
182 self.load_document(xml_doc, isinstance(name, (int, long)) and name or None, qwebcontext=qwebcontext)
184 if name in qwebcontext.templates:
185 return qwebcontext.templates[name]
187 raise QWebTemplateNotFound("Template %r not found" % name, template=origin_template)
189 def eval(self, expr, qwebcontext):
191 return qwebcontext.safe_eval(expr)
193 template = qwebcontext.get('__template__')
194 raise_qweb_exception(message="Could not evaluate expression %r" % expr, expression=expr, template=template)
196 def eval_object(self, expr, qwebcontext):
197 return self.eval(expr, qwebcontext)
199 def eval_str(self, expr, qwebcontext):
201 return qwebcontext.get(0, '')
202 val = self.eval(expr, qwebcontext)
203 if isinstance(val, unicode):
204 return val.encode("utf8")
205 if val is False or val is None:
209 def eval_format(self, expr, qwebcontext):
210 expr, replacements = self._format_regex.subn(
211 lambda m: self.eval_str(m.group(1) or m.group(2), qwebcontext),
219 return str(expr % qwebcontext)
221 template = qwebcontext.get('__template__')
222 raise_qweb_exception(message="Format error for expression %r" % expr, expression=expr, template=template)
224 def eval_bool(self, expr, qwebcontext):
225 return int(bool(self.eval(expr, qwebcontext)))
227 def render(self, cr, uid, id_or_xml_id, qwebcontext=None, loader=None, context=None):
228 if qwebcontext is None:
231 if not isinstance(qwebcontext, QWebContext):
232 qwebcontext = QWebContext(cr, uid, qwebcontext, loader=loader, context=context)
234 qwebcontext['__template__'] = id_or_xml_id
235 stack = qwebcontext.get('__stack__', [])
237 qwebcontext['__caller__'] = stack[-1]
238 stack.append(id_or_xml_id)
239 qwebcontext['__stack__'] = stack
240 qwebcontext['xmlid'] = str(stack[0]) # Temporary fix
241 return self.render_node(self.get_template(id_or_xml_id, qwebcontext), qwebcontext)
243 def render_node(self, element, qwebcontext):
245 if element.nodeType == self.node.TEXT_NODE or element.nodeType == self.node.CDATA_SECTION_NODE:
246 result = element.data.encode("utf8")
247 elif element.nodeType == self.node.ELEMENT_NODE:
248 generated_attributes = ""
250 template_attributes = {}
251 for (attribute_name, attribute_value) in element.attributes.items():
252 attribute_name = str(attribute_name)
253 if attribute_name == "groups":
254 cr = qwebcontext.get('request') and qwebcontext['request'].cr or None
255 uid = qwebcontext.get('request') and qwebcontext['request'].uid or None
256 can_see = self.user_has_groups(cr, uid, groups=attribute_value) if cr and uid else False
261 if isinstance(attribute_value, unicode):
262 attribute_value = attribute_value.encode("utf8")
264 attribute_value = attribute_value.nodeValue.encode("utf8")
266 if attribute_name.startswith("t-"):
267 for attribute in self._render_att:
268 if attribute_name[2:].startswith(attribute):
269 att, val = self._render_att[attribute](self, element, attribute_name, attribute_value, qwebcontext)
270 generated_attributes += val and ' %s="%s"' % (att, werkzeug.utils.escape(val)) or " "
273 if attribute_name[2:] in self._render_tag:
274 t_render = attribute_name[2:]
275 template_attributes[attribute_name[2:]] = attribute_value
277 generated_attributes += ' %s="%s"' % (attribute_name, werkzeug.utils.escape(attribute_value))
279 if 'debug' in template_attributes:
280 debugger = template_attributes.get('debug', 'pdb')
281 __import__(debugger).set_trace() # pdb, ipdb, pudb, ...
283 result = self._render_tag[t_render](self, element, template_attributes, generated_attributes, qwebcontext)
285 result = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
286 if isinstance(result, unicode):
287 return result.encode('utf-8')
290 def render_element(self, element, template_attributes, generated_attributes, qwebcontext, inner=None):
292 # template_attributes: t-* attributes
293 # generated_attributes: generated attributes
294 # qwebcontext: values
295 # inner: optional innerXml
300 for current_node in element.childNodes:
302 g_inner.append(self.render_node(current_node, qwebcontext))
303 except QWebException:
306 template = qwebcontext.get('__template__')
307 raise_qweb_exception(message="Could not render element %r" % element.nodeName, node=element, template=template)
308 name = str(element.nodeName)
309 inner = "".join(g_inner)
310 trim = template_attributes.get("trim", 0)
314 inner = inner.lstrip()
315 elif trim == 'right':
316 inner = inner.rstrip()
318 inner = inner.strip()
321 elif len(inner) or name not in self._void_elements:
322 return "<%s%s>%s</%s>" % tuple(
323 qwebcontext if isinstance(qwebcontext, str) else qwebcontext.encode('utf-8')
324 for qwebcontext in (name, generated_attributes, inner, name)
327 return "<%s%s/>" % (name, generated_attributes)
330 def render_att_att(self, element, attribute_name, attribute_value, qwebcontext):
331 if attribute_name.startswith("t-attf-"):
332 att, val = attribute_name[7:], self.eval_format(attribute_value, qwebcontext)
333 elif attribute_name.startswith("t-att-"):
334 att, val = attribute_name[6:], self.eval(attribute_value, qwebcontext)
335 if isinstance(val, unicode):
336 val = val.encode("utf8")
338 att, val = self.eval_object(attribute_value, qwebcontext)
342 def render_tag_raw(self, element, template_attributes, generated_attributes, qwebcontext):
343 inner = self.eval_str(template_attributes["raw"], qwebcontext)
344 return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
346 def render_tag_esc(self, element, template_attributes, generated_attributes, qwebcontext):
347 options = json.loads(template_attributes.get('esc-options') or '{}')
348 widget = self.get_widget_for(options.get('widget', ''))
349 inner = widget.format(template_attributes['esc'], options, qwebcontext)
350 return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
352 def render_tag_foreach(self, element, template_attributes, generated_attributes, qwebcontext):
353 expr = template_attributes["foreach"]
354 enum = self.eval_object(expr, qwebcontext)
356 var = template_attributes.get('as', expr).replace('.', '_')
357 copy_qwebcontext = qwebcontext.copy()
359 if isinstance(enum, (list, tuple)):
361 elif hasattr(enum, 'count'):
363 copy_qwebcontext["%s_size" % var] = size
364 copy_qwebcontext["%s_all" % var] = enum
368 copy_qwebcontext["%s_value" % var] = i
369 copy_qwebcontext["%s_index" % var] = index
370 copy_qwebcontext["%s_first" % var] = index == 0
371 copy_qwebcontext["%s_even" % var] = index % 2
372 copy_qwebcontext["%s_odd" % var] = (index + 1) % 2
373 copy_qwebcontext["%s_last" % var] = index + 1 == size
375 copy_qwebcontext["%s_parity" % var] = 'odd'
377 copy_qwebcontext["%s_parity" % var] = 'even'
378 if 'as' in template_attributes:
379 copy_qwebcontext[var] = i
380 elif isinstance(i, dict):
381 copy_qwebcontext.update(i)
382 ru.append(self.render_element(element, template_attributes, generated_attributes, copy_qwebcontext))
386 template = qwebcontext.get('__template__')
387 raise QWebException("foreach enumerator %r is not defined while rendering template %r" % (expr, template), template=template)
389 def render_tag_if(self, element, template_attributes, generated_attributes, qwebcontext):
390 if self.eval_bool(template_attributes["if"], qwebcontext):
391 return self.render_element(element, template_attributes, generated_attributes, qwebcontext)
394 def render_tag_call(self, element, template_attributes, generated_attributes, qwebcontext):
395 d = qwebcontext.copy()
396 d[0] = self.render_element(element, template_attributes, generated_attributes, d)
397 cr = d.get('request') and d['request'].cr or None
398 uid = d.get('request') and d['request'].uid or None
400 template = self.eval_format(template_attributes["call"], d)
402 template = int(template)
405 return self.render(cr, uid, template, d)
407 def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
408 """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
409 name = template_attributes['call-assets']
411 # Backward compatibility hack for manifest usage
412 qwebcontext['manifest_list'] = openerp.addons.web.controllers.main.manifest_list
414 d = qwebcontext.copy()
415 d.context['inherit_branding'] = False
416 content = self.render_tag_call(
417 element, {'call': name}, generated_attributes, d)
418 if qwebcontext.get('debug'):
420 bundle = AssetsBundle(name, html=content)
421 return bundle.to_html()
423 def render_tag_set(self, element, template_attributes, generated_attributes, qwebcontext):
424 if "value" in template_attributes:
425 qwebcontext[template_attributes["set"]] = self.eval_object(template_attributes["value"], qwebcontext)
426 elif "valuef" in template_attributes:
427 qwebcontext[template_attributes["set"]] = self.eval_format(template_attributes["valuef"], qwebcontext)
429 qwebcontext[template_attributes["set"]] = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
432 def render_tag_field(self, element, template_attributes, generated_attributes, qwebcontext):
433 """ eg: <span t-record="browse_record(res.partner, 1)" t-field="phone">+1 555 555 8069</span>"""
434 node_name = element.nodeName
435 assert node_name not in ("table", "tbody", "thead", "tfoot", "tr", "td",
436 "li", "ul", "ol", "dl", "dt", "dd"),\
437 "RTE widgets do not work correctly on %r elements" % node_name
438 assert node_name != 't',\
439 "t-field can not be used on a t element, provide an actual HTML node"
441 record, field_name = template_attributes["field"].rsplit('.', 1)
442 record = self.eval_object(record, qwebcontext)
444 column = record._model._all_columns[field_name].column
445 options = json.loads(template_attributes.get('field-options') or '{}')
446 field_type = get_field_type(column, options)
448 converter = self.get_converter_for(field_type)
450 return converter.to_html(qwebcontext.cr, qwebcontext.uid, field_name, record, options,
451 element, template_attributes, generated_attributes, qwebcontext, context=qwebcontext.context)
453 def get_converter_for(self, field_type):
454 return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
456 def get_widget_for(self, widget):
457 return self.pool.get('ir.qweb.widget.' + widget, self.pool['ir.qweb.widget'])
459 #--------------------------------------------------------------------
460 # QWeb Fields converters
461 #--------------------------------------------------------------------
463 class FieldConverter(osv.AbstractModel):
464 """ Used to convert a t-field specification into an output HTML field.
466 :meth:`~.to_html` is the entry point of this conversion from QWeb, it:
468 * converts the record value to html using :meth:`~.record_to_html`
469 * generates the metadata attributes (``data-oe-``) to set on the root
471 * generates the root result node itself through :meth:`~.render_element`
473 _name = 'ir.qweb.field'
475 def attributes(self, cr, uid, field_name, record, options,
476 source_element, g_att, t_att, qweb_context,
479 Generates the metadata attributes (prefixed by ``data-oe-`` for the
480 root node of the field conversion. Attribute values are escaped by the
481 parent using ``werkzeug.utils.escape``.
483 The default attributes are:
485 * ``model``, the name of the record's model
486 * ``id`` the id of the record to which the field belongs
487 * ``field`` the name of the converted field
488 * ``type`` the logical field type (widget, may not match the column's
489 ``type``, may not be any _column subclass name)
490 * ``translate``, a boolean flag (``0`` or ``1``) denoting whether the
491 column is translatable
492 * ``expression``, the original expression
494 :returns: iterable of (attribute name, attribute value) pairs.
496 column = record._model._all_columns[field_name].column
497 field_type = get_field_type(column, options)
499 ('data-oe-model', record._model._name),
500 ('data-oe-id', record.id),
501 ('data-oe-field', field_name),
502 ('data-oe-type', field_type),
503 ('data-oe-expression', t_att['field']),
506 def value_to_html(self, cr, uid, value, column, options=None, context=None):
507 """ Converts a single value to its HTML version/output
509 if not value: return ''
512 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
513 """ Converts the specified field of the browse_record ``record`` to
516 return self.value_to_html(
517 cr, uid, record[field_name], column, options=options, context=context)
519 def to_html(self, cr, uid, field_name, record, options,
520 source_element, t_att, g_att, qweb_context, context=None):
521 """ Converts a ``t-field`` to its HTML output. A ``t-field`` may be
522 extended by a ``t-field-options``, which is a JSON-serialized mapping
523 of configuration values.
525 A default configuration key is ``widget`` which can override the
526 field's own ``_type``.
529 content = self.record_to_html(
530 cr, uid, field_name, record,
531 record._model._all_columns[field_name].column,
532 options, context=context)
533 if options.get('html-escape', True):
534 content = werkzeug.utils.escape(content)
535 elif hasattr(content, '__html__'):
536 content = content.__html__()
538 _logger.warning("Could not get field %s for model %s",
539 field_name, record._model._name, exc_info=True)
542 if context and context.get('inherit_branding'):
543 # add branding attributes
545 ' %s="%s"' % (name, werkzeug.utils.escape(value))
546 for name, value in self.attributes(
547 cr, uid, field_name, record, options,
548 source_element, g_att, t_att, qweb_context)
551 return self.render_element(cr, uid, source_element, t_att, g_att,
552 qweb_context, content)
554 def qweb_object(self):
555 return self.pool['ir.qweb']
557 def render_element(self, cr, uid, source_element, t_att, g_att,
558 qweb_context, content):
559 """ Final rendering hook, by default just calls ir.qweb's ``render_element``
561 return self.qweb_object().render_element(
562 source_element, t_att, g_att, qweb_context, content or '')
564 def user_lang(self, cr, uid, context):
566 Fetches the res.lang object corresponding to the language code stored
567 in the user's context. Fallbacks to en_US if no lang is present in the
568 context *or the language code is not valid*.
570 :returns: res.lang browse_record
572 if context is None: context = {}
574 lang_code = context.get('lang') or 'en_US'
575 Lang = self.pool['res.lang']
577 lang_ids = Lang.search(cr, uid, [('code', '=', lang_code)], context=context) \
578 or Lang.search(cr, uid, [('code', '=', 'en_US')], context=context)
580 return Lang.browse(cr, uid, lang_ids[0], context=context)
582 class FloatConverter(osv.AbstractModel):
583 _name = 'ir.qweb.field.float'
584 _inherit = 'ir.qweb.field'
586 def precision(self, cr, uid, column, options=None, context=None):
587 _, precision = column.digits or (None, None)
590 def value_to_html(self, cr, uid, value, column, options=None, context=None):
593 precision = self.precision(cr, uid, column, options=options, context=context)
594 fmt = '%f' if precision is None else '%.{precision}f'
596 lang_code = context.get('lang') or 'en_US'
597 lang = self.pool['res.lang']
598 formatted = lang.format(cr, uid, [lang_code], fmt.format(precision=precision), value, grouping=True)
600 # %f does not strip trailing zeroes. %g does but its precision causes
601 # it to switch to scientific notation starting at a million *and* to
602 # strip decimals. So use %f and if no precision was specified manually
605 formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted)
608 class DateConverter(osv.AbstractModel):
609 _name = 'ir.qweb.field.date'
610 _inherit = 'ir.qweb.field'
612 def value_to_html(self, cr, uid, value, column, options=None, context=None):
613 if not value: return ''
614 lang = self.user_lang(cr, uid, context=context)
615 locale = babel.Locale.parse(lang.code)
617 if isinstance(value, basestring):
618 value = datetime.datetime.strptime(
619 value, openerp.tools.DEFAULT_SERVER_DATE_FORMAT)
621 if options and 'format' in options:
622 pattern = options['format']
624 strftime_pattern = lang.date_format
625 pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
627 return babel.dates.format_datetime(
628 value, format=pattern,
631 class DateTimeConverter(osv.AbstractModel):
632 _name = 'ir.qweb.field.datetime'
633 _inherit = 'ir.qweb.field'
635 def value_to_html(self, cr, uid, value, column, options=None, context=None):
636 if not value: return ''
637 lang = self.user_lang(cr, uid, context=context)
638 locale = babel.Locale.parse(lang.code)
640 if isinstance(value, basestring):
641 value = datetime.datetime.strptime(
642 value, openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT)
643 value = fields.datetime.context_timestamp(
644 cr, uid, timestamp=value, context=context)
646 if options and 'format' in options:
647 pattern = options['format']
649 strftime_pattern = (u"%s %s" % (lang.date_format, lang.time_format))
650 pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
652 if options and options.get('hide_seconds'):
653 pattern = pattern.replace(":ss", "").replace(":s", "")
655 return babel.dates.format_datetime(value, format=pattern, locale=locale)
657 class TextConverter(osv.AbstractModel):
658 _name = 'ir.qweb.field.text'
659 _inherit = 'ir.qweb.field'
661 def value_to_html(self, cr, uid, value, column, options=None, context=None):
663 Escapes the value and converts newlines to br. This is bullshit.
665 if not value: return ''
667 return nl2br(value, options=options)
669 class SelectionConverter(osv.AbstractModel):
670 _name = 'ir.qweb.field.selection'
671 _inherit = 'ir.qweb.field'
673 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
674 value = record[field_name]
675 if not value: return ''
676 selection = dict(fields.selection.reify(
677 cr, uid, record._model, column))
678 return self.value_to_html(
679 cr, uid, selection[value], column, options=options)
681 class ManyToOneConverter(osv.AbstractModel):
682 _name = 'ir.qweb.field.many2one'
683 _inherit = 'ir.qweb.field'
685 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
686 [read] = record.read([field_name])
687 if not read[field_name]: return ''
688 _, value = read[field_name]
689 return nl2br(value, options=options)
691 class HTMLConverter(osv.AbstractModel):
692 _name = 'ir.qweb.field.html'
693 _inherit = 'ir.qweb.field'
695 def value_to_html(self, cr, uid, value, column, options=None, context=None):
696 return HTMLSafe(value or '')
698 class ImageConverter(osv.AbstractModel):
699 """ ``image`` widget rendering, inserts a data:uri-using image tag in the
700 document. May be overridden by e.g. the website module to generate links
703 .. todo:: what happens if different output need different converters? e.g.
704 reports may need embedded images or FS links whereas website
707 _name = 'ir.qweb.field.image'
708 _inherit = 'ir.qweb.field'
710 def value_to_html(self, cr, uid, value, column, options=None, context=None):
712 image = Image.open(cStringIO.StringIO(value.decode('base64')))
715 raise ValueError("Non-image binary fields can not be converted to HTML")
716 except: # image.verify() throws "suitable exceptions", I have no idea what they are
717 raise ValueError("Invalid image content")
719 return HTMLSafe('<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value))
721 class MonetaryConverter(osv.AbstractModel):
722 """ ``monetary`` converter, has a mandatory option
723 ``display_currency``.
725 The currency is used for formatting *and rounding* of the float value. It
726 is assumed that the linked res_currency has a non-empty rounding value and
727 res.currency's ``round`` method is used to perform rounding.
729 .. note:: the monetary converter internally adds the qweb context to its
730 options mapping, so that the context is available to callees.
731 It's set under the ``_qweb_context`` key.
733 _name = 'ir.qweb.field.monetary'
734 _inherit = 'ir.qweb.field'
736 def to_html(self, cr, uid, field_name, record, options,
737 source_element, t_att, g_att, qweb_context, context=None):
738 options['_qweb_context'] = qweb_context
739 return super(MonetaryConverter, self).to_html(
740 cr, uid, field_name, record, options,
741 source_element, t_att, g_att, qweb_context, context=context)
743 def record_to_html(self, cr, uid, field_name, record, column, options, context=None):
746 Currency = self.pool['res.currency']
747 display = self.display_currency(cr, uid, options)
749 # lang.format mandates a sprintf-style format. These formats are non-
750 # minimal (they have a default fixed precision instead), and
751 # lang.format will not set one by default. currency.round will not
752 # provide one either. So we need to generate a precision value
753 # (integer > 0) from the currency's rounding (a float generally < 1.0).
755 # The log10 of the rounding should be the number of digits involved if
756 # negative, if positive clamp to 0 digits and call it a day.
757 # nb: int() ~ floor(), we want nearest rounding instead
758 precision = int(round(math.log10(display.rounding)))
759 fmt = "%.{0}f".format(-precision if precision < 0 else 0)
761 lang_code = context.get('lang') or 'en_US'
762 lang = self.pool['res.lang']
763 formatted_amount = lang.format(cr, uid, [lang_code],
764 fmt, Currency.round(cr, uid, display, record[field_name]),
765 grouping=True, monetary=True)
768 if display.position == 'before':
773 return HTMLSafe(u'{pre}<span class="oe_currency_value">{0}</span>{post}'.format(
777 symbol=display.symbol,
780 def display_currency(self, cr, uid, options):
781 return self.qweb_object().eval_object(
782 options['display_currency'], options['_qweb_context'])
785 ('year', 3600 * 24 * 365),
786 ('month', 3600 * 24 * 30),
787 ('week', 3600 * 24 * 7),
793 class DurationConverter(osv.AbstractModel):
794 """ ``duration`` converter, to display integral or fractional values as
795 human-readable time spans (e.g. 1.5 as "1 hour 30 minutes").
797 Can be used on any numerical field.
799 Has a mandatory option ``unit`` which can be one of ``second``, ``minute``,
800 ``hour``, ``day``, ``week`` or ``year``, used to interpret the numerical
801 field value before converting it.
803 Sub-second values will be ignored.
805 _name = 'ir.qweb.field.duration'
806 _inherit = 'ir.qweb.field'
808 def value_to_html(self, cr, uid, value, column, options=None, context=None):
809 units = dict(TIMEDELTA_UNITS)
811 raise ValueError(_("Durations can't be negative"))
812 if not options or options.get('unit') not in units:
813 raise ValueError(_("A unit must be provided to duration widgets"))
815 locale = babel.Locale.parse(
816 self.user_lang(cr, uid, context=context).code)
817 factor = units[options['unit']]
821 for unit, secs_per_unit in TIMEDELTA_UNITS:
822 v, r = divmod(r, secs_per_unit)
824 section = babel.dates.format_timedelta(
825 v*secs_per_unit, threshold=1, locale=locale)
827 sections.append(section)
828 return u' '.join(sections)
830 class RelativeDatetimeConverter(osv.AbstractModel):
831 _name = 'ir.qweb.field.relative'
832 _inherit = 'ir.qweb.field'
834 def value_to_html(self, cr, uid, value, column, options=None, context=None):
835 parse_format = openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT
836 locale = babel.Locale.parse(
837 self.user_lang(cr, uid, context=context).code)
839 if isinstance(value, basestring):
840 value = datetime.datetime.strptime(value, parse_format)
842 # value should be a naive datetime in UTC. So is fields.datetime.now()
843 reference = datetime.datetime.strptime(column.now(), parse_format)
845 return babel.dates.format_timedelta(
846 value - reference, add_direction=True, locale=locale)
848 class Contact(orm.AbstractModel):
849 _name = 'ir.qweb.field.contact'
850 _inherit = 'ir.qweb.field.many2one'
852 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
855 opf = options.get('fields') or ["name", "address", "phone", "mobile", "fax", "email"]
857 if not getattr(record, field_name):
860 id = getattr(record, field_name).id
861 field_browse = self.pool[column._obj].browse(cr, openerp.SUPERUSER_ID, id, context={"show_address": True})
862 value = field_browse.name_get()[0][1]
865 'name': value.split("\n")[0],
866 'address': werkzeug.utils.escape("\n".join(value.split("\n")[1:])),
867 'phone': field_browse.phone,
868 'mobile': field_browse.mobile,
869 'fax': field_browse.fax,
870 'city': field_browse.city,
871 'country_id': field_browse.country_id and field_browse.country_id.name_get()[0][1],
872 'email': field_browse.email,
874 'object': field_browse,
878 html = self.pool["ir.ui.view"].render(cr, uid, "base.contact", val, engine='ir.qweb', context=context).decode('utf8')
880 return HTMLSafe(html)
882 class QwebView(orm.AbstractModel):
883 _name = 'ir.qweb.field.qweb'
884 _inherit = 'ir.qweb.field.many2one'
886 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
887 if not getattr(record, field_name):
890 view = getattr(record, field_name)
892 if view._model._name != "ir.ui.view":
893 _logger.warning("%s.%s must be a 'ir.ui.view' model." % (record, field_name))
896 ctx = (context or {}).copy()
897 ctx['object'] = record
898 html = view.render(ctx, engine='ir.qweb', context=ctx).decode('utf8')
900 return HTMLSafe(html)
902 class QwebWidget(osv.AbstractModel):
903 _name = 'ir.qweb.widget'
905 def _format(self, inner, options, qwebcontext):
906 return self.pool['ir.qweb'].eval_str(inner, qwebcontext)
908 def format(self, inner, options, qwebcontext):
909 return werkzeug.utils.escape(self._format(inner, options, qwebcontext))
911 class QwebWidgetMonetary(osv.AbstractModel):
912 _name = 'ir.qweb.widget.monetary'
913 _inherit = 'ir.qweb.widget'
915 def _format(self, inner, options, qwebcontext):
916 inner = self.pool['ir.qweb'].eval(inner, qwebcontext)
917 display = self.pool['ir.qweb'].eval_object(options['display_currency'], qwebcontext)
918 precision = int(round(math.log10(display.rounding)))
919 fmt = "%.{0}f".format(-precision if precision < 0 else 0)
920 lang_code = qwebcontext.context.get('lang') or 'en_US'
921 formatted_amount = self.pool['res.lang'].format(
922 qwebcontext.cr, qwebcontext.uid, [lang_code], fmt, inner, grouping=True, monetary=True
925 if display.position == 'before':
930 return u'{pre}{0}{post}'.format(
931 formatted_amount, pre=pre, post=post
932 ).format(symbol=display.symbol,)
934 class HTMLSafe(object):
935 """ HTMLSafe string wrapper, Werkzeug's escape() has special handling for
936 objects with a ``__html__`` methods but AFAIK does not provide any such
939 Wrapping a string in HTML will prevent its escaping
941 __slots__ = ['string']
942 def __init__(self, string):
948 if isinstance(s, unicode):
949 return s.encode('utf-8')
951 def __unicode__(self):
953 if isinstance(s, str):
954 return s.decode('utf-8')
957 def nl2br(string, options=None):
958 """ Converts newlines to HTML linebreaks in ``string``. Automatically
959 escapes content unless options['html-escape'] is set to False, and returns
960 the result wrapped in an HTMLSafe object.
966 if options is None: options = {}
968 if options.get('html-escape', True):
969 string = werkzeug.utils.escape(string)
970 return HTMLSafe(string.replace('\n', '<br>\n'))
972 def get_field_type(column, options):
973 """ Gets a t-field's effective type from the field's column and its options
975 return options.get('widget', column._type)
977 class AssetsBundle(object):
978 cache = openerp.tools.lru.LRU(32)
979 rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
981 def __init__(self, xmlid, html=None, debug=False):
984 self.javascripts = []
985 self.stylesheets = []
987 self._checksum = None
991 def parse(self, html):
992 fragments = lxml.html.fragments_fromstring(html)
994 if isinstance(el, basestring):
995 self.remains.append(el)
996 elif isinstance(el, lxml.html.HtmlElement):
998 href = el.get('href')
999 if el.tag == 'style':
1000 self.stylesheets.append(StylesheetAsset(source=el.text))
1001 elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href):
1002 self.stylesheets.append(StylesheetAsset(url=href))
1003 elif el.tag == 'script' and not src:
1004 self.javascripts.append(JavascriptAsset(source=el.text))
1005 elif el.tag == 'script' and self.can_aggregate(src):
1006 self.javascripts.append(JavascriptAsset(url=src))
1008 self.remains.append(lxml.html.tostring(el))
1011 self.remains.append(lxml.html.tostring(el))
1013 # notYETimplementederror
1014 raise NotImplementedError
1016 def can_aggregate(self, url):
1017 return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js'))
1019 def to_html(self, sep='\n'):
1021 if self.stylesheets:
1022 response.append('<link href="/web/css/%s" rel="stylesheet"/>' % self.xmlid)
1023 if self.javascripts:
1024 response.append('<script type="text/javascript" src="/web/js/%s" defer="defer"></script>' % self.xmlid)
1025 response.extend(self.remains)
1027 return sep.join(response)
1029 @openerp.tools.func.lazy_property
1030 def last_modified(self):
1031 return max(itertools.chain(
1032 (asset.last_modified for asset in self.javascripts),
1033 (asset.last_modified for asset in self.stylesheets),
1034 [datetime.datetime(1970, 1, 1)],
1037 @openerp.tools.func.lazy_property
1039 checksum = hashlib.new('sha1')
1040 for asset in itertools.chain(self.javascripts, self.stylesheets):
1041 checksum.update(asset.content.encode("utf-8"))
1042 return checksum.hexdigest()
1045 key = 'js_' + self.checksum
1046 if key not in self.cache:
1047 content =';\n'.join(asset.minify() for asset in self.javascripts)
1048 self.cache[key] = content
1050 return "/*\n%s\n*/\n" % '\n'.join(
1051 [asset.filename for asset in self.javascripts if asset.filename]) + self.cache[key]
1052 return self.cache[key]
1055 key = 'css_' + self.checksum
1056 if key not in self.cache:
1057 content = '\n'.join(asset.minify() for asset in self.stylesheets)
1058 # move up all @import rules to the top
1061 matches.append(matchobj.group(0))
1064 content = re.sub(self.rx_css_import, push, content)
1066 matches.append(content)
1067 content = u'\n'.join(matches)
1068 self.cache[key] = content
1070 return "/*\n%s\n*/\n" % '\n'.join(
1071 [asset.filename for asset in self.javascripts if asset.filename]) + self.cache[key]
1072 return self.cache[key]
1074 class WebAsset(object):
1075 def __init__(self, source=None, url=None):
1076 self.source = source
1078 self._filename = None
1079 self._content = None
1083 if self._filename is None and self.url:
1084 module = filter(None, self.url.split('/'))[0]
1086 mpath = openerp.http.addons_manifest[module]['addons_path']
1088 raise KeyError("Could not find asset '%s' for '%s' addon" % (self.url, module))
1089 self._filename = mpath + self.url.replace('/', os.path.sep)
1090 return self._filename
1094 if self._content is None:
1095 self._content = self.get_content()
1096 return self._content
1098 def get_content(self):
1102 with open(self.filename, 'rb') as fp:
1103 return fp.read().decode('utf-8')
1109 def last_modified(self):
1111 # TODO: return last_update of bundle's ir.ui.view
1112 return datetime.datetime(1970, 1, 1)
1113 return datetime.datetime.fromtimestamp(os.path.getmtime(self.filename))
1115 class JavascriptAsset(WebAsset):
1117 return rjsmin(self.content)
1119 class StylesheetAsset(WebAsset):
1120 rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
1121 rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
1122 rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
1124 def _get_content(self):
1128 with open(self.filename, 'rb') as fp:
1129 firstline = fp.readline()
1130 m = re.match(r'@charset "([^"]+)";', firstline)
1132 encoding = m.group(1)
1135 # "reinject" first line as it's not @charset
1138 return fp.read().decode(encoding)
1140 def get_content(self):
1141 content = self._get_content()
1143 web_dir = os.path.dirname(self.url)
1145 content = self.rx_import.sub(
1146 r"""@import \1%s/""" % (web_dir,),
1150 content = self.rx_url.sub(
1151 r"url(\1%s/" % (web_dir,),
1157 # remove existing sourcemaps, make no sense after re-mini
1158 content = self.rx_sourceMap.sub('', self.content)
1160 content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
1162 content = re.sub(r'\s+', ' ', content)
1163 content = re.sub(r' *([{}]) *', r'\1', content)
1167 """ Minify js with a clever regex.
1168 Taken from http://opensource.perlig.de/rjsmin
1169 Apache License, Version 2.0 """
1171 """ Substitution callback """
1172 groups = match.groups()
1178 (groups[4] and '\n') or
1179 (groups[5] and ' ') or
1180 (groups[6] and ' ') or
1181 (groups[7] and ' ') or
1186 r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
1187 r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
1188 r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
1189 r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
1190 r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
1191 r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
1192 r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
1193 r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
1194 r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
1195 r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
1196 r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
1197 r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
1198 r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
1199 r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
1200 r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
1201 r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
1202 r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
1203 r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
1204 r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
1205 r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
1206 r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script