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.http import request
27 from openerp.tools.safe_eval import safe_eval as eval
28 from openerp.osv import osv, orm, fields
29 from openerp.tools.translate import _
30 from openerp import SUPERUSER_ID
32 _logger = logging.getLogger(__name__)
34 #--------------------------------------------------------------------
35 # QWeb template engine
36 #--------------------------------------------------------------------
37 class QWebException(Exception):
38 def __init__(self, message, **kw):
39 Exception.__init__(self, message)
42 class QWebTemplateNotFound(QWebException):
45 def raise_qweb_exception(etype=None, **kw):
48 orig_type, original, tb = sys.exc_info()
50 raise etype, original, tb
52 for k, v in kw.items():
54 # Will use `raise foo from bar` in python 3 and rename cause to __cause__
55 e.qweb['cause'] = original
58 class QWebContext(dict):
59 def __init__(self, cr, uid, data, loader=None, templates=None, context=None):
63 self.templates = templates or {}
64 self.context = context
66 super(QWebContext, self).__init__(dic)
67 self['defined'] = lambda key: key in self
69 def safe_eval(self, expr):
70 locals_dict = collections.defaultdict(lambda: None)
71 locals_dict.update(self)
72 locals_dict.pop('cr', None)
73 locals_dict.pop('loader', None)
74 return eval(expr, None, locals_dict, nocopy=True, locals_builtins=True)
77 return QWebContext(self.cr, self.uid, dict.copy(self),
79 templates=self.templates,
85 class QWeb(orm.AbstractModel):
86 """QWeb Xml templating engine
88 The templating engine use a very simple syntax based "magic" xml
89 attributes, to produce textual output (even non-xml).
91 The core magic attributes are:
97 t-att t-raw t-esc t-trim
99 assignation attribute:
102 QWeb can be extended like any OpenERP model and new attributes can be
105 If you need to customize t-fields rendering, subclass the ir.qweb.field
106 model (and its sub-models) then override :meth:`~.get_converter_for` to
107 fetch the right field converters for your qweb model.
109 Beware that if you need extensions or alterations which could be
110 incompatible with other subsystems, you should create a local object
111 inheriting from ``ir.qweb`` and customize that.
117 _void_elements = frozenset([
118 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
119 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'])
120 _format_regex = re.compile(
125 # jinja-style pattern
129 def __init__(self, pool, cr):
130 super(QWeb, self).__init__(pool, cr)
132 self._render_tag = self.prefixed_methods('render_tag_')
133 self._render_att = self.prefixed_methods('render_att_')
135 def prefixed_methods(self, prefix):
136 """ Extracts all methods prefixed by ``prefix``, and returns a mapping
137 of (t-name, method) where the t-name is the method name with prefix
138 removed and underscore converted to dashes
143 n_prefix = len(prefix)
145 (name[n_prefix:].replace('_', '-'), getattr(type(self), name))
146 for name in dir(self)
147 if name.startswith(prefix)
150 def register_tag(self, tag, func):
151 self._render_tag[tag] = func
153 def add_template(self, qwebcontext, name, node):
154 """Add a parsed template in the context. Used to preprocess templates."""
155 qwebcontext.templates[name] = node
157 def load_document(self, document, res_id, qwebcontext):
159 Loads an XML document and installs any contained template in the engine
161 if hasattr(document, 'documentElement'):
163 elif document.startswith("<?xml"):
164 dom = xml.dom.minidom.parseString(document)
166 dom = xml.dom.minidom.parse(document)
168 for node in dom.documentElement.childNodes:
169 if node.nodeType == self.node.ELEMENT_NODE:
170 if node.getAttribute('t-name'):
171 name = str(node.getAttribute("t-name"))
172 self.add_template(qwebcontext, name, node)
173 if res_id and node.tagName == "t":
174 self.add_template(qwebcontext, res_id, node)
177 def get_template(self, name, qwebcontext):
178 origin_template = qwebcontext.get('__caller__') or qwebcontext['__stack__'][0]
179 if qwebcontext.loader and name not in qwebcontext.templates:
181 xml_doc = qwebcontext.loader(name)
183 raise_qweb_exception(QWebTemplateNotFound, message="Loader could not find template %r" % name, template=origin_template)
184 self.load_document(xml_doc, isinstance(name, (int, long)) and name or None, qwebcontext=qwebcontext)
186 if name in qwebcontext.templates:
187 return qwebcontext.templates[name]
189 raise QWebTemplateNotFound("Template %r not found" % name, template=origin_template)
191 def eval(self, expr, qwebcontext):
193 return qwebcontext.safe_eval(expr)
195 template = qwebcontext.get('__template__')
196 raise_qweb_exception(message="Could not evaluate expression %r" % expr, expression=expr, template=template)
198 def eval_object(self, expr, qwebcontext):
199 return self.eval(expr, qwebcontext)
201 def eval_str(self, expr, qwebcontext):
203 return qwebcontext.get(0, '')
204 val = self.eval(expr, qwebcontext)
205 if isinstance(val, unicode):
206 return val.encode("utf8")
207 if val is False or val is None:
211 def eval_format(self, expr, qwebcontext):
212 expr, replacements = self._format_regex.subn(
213 lambda m: self.eval_str(m.group(1) or m.group(2), qwebcontext),
221 return str(expr % qwebcontext)
223 template = qwebcontext.get('__template__')
224 raise_qweb_exception(message="Format error for expression %r" % expr, expression=expr, template=template)
226 def eval_bool(self, expr, qwebcontext):
227 return int(bool(self.eval(expr, qwebcontext)))
229 def render(self, cr, uid, id_or_xml_id, qwebcontext=None, loader=None, context=None):
230 if qwebcontext is None:
233 if not isinstance(qwebcontext, QWebContext):
234 qwebcontext = QWebContext(cr, uid, qwebcontext, loader=loader, context=context)
236 qwebcontext['__template__'] = id_or_xml_id
237 stack = qwebcontext.get('__stack__', [])
239 qwebcontext['__caller__'] = stack[-1]
240 stack.append(id_or_xml_id)
241 qwebcontext['__stack__'] = stack
242 qwebcontext['xmlid'] = str(stack[0]) # Temporary fix
243 return self.render_node(self.get_template(id_or_xml_id, qwebcontext), qwebcontext)
245 def render_node(self, element, qwebcontext):
247 if element.nodeType == self.node.TEXT_NODE or element.nodeType == self.node.CDATA_SECTION_NODE:
248 result = element.data.encode("utf8")
249 elif element.nodeType == self.node.ELEMENT_NODE:
250 generated_attributes = ""
252 template_attributes = {}
253 for (attribute_name, attribute_value) in element.attributes.items():
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
263 if isinstance(attribute_value, unicode):
264 attribute_value = attribute_value.encode("utf8")
266 attribute_value = attribute_value.nodeValue.encode("utf8")
268 if attribute_name.startswith("t-"):
269 for attribute in self._render_att:
270 if attribute_name[2:].startswith(attribute):
271 att, val = self._render_att[attribute](self, element, attribute_name, attribute_value, qwebcontext)
272 generated_attributes += val and ' %s="%s"' % (att, werkzeug.utils.escape(val)) or " "
275 if attribute_name[2:] in self._render_tag:
276 t_render = attribute_name[2:]
277 template_attributes[attribute_name[2:]] = attribute_value
279 generated_attributes += ' %s="%s"' % (attribute_name, werkzeug.utils.escape(attribute_value))
281 if 'debug' in template_attributes:
282 debugger = template_attributes.get('debug', 'pdb')
283 __import__(debugger).set_trace() # pdb, ipdb, pudb, ...
285 result = self._render_tag[t_render](self, element, template_attributes, generated_attributes, qwebcontext)
287 result = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
288 if isinstance(result, unicode):
289 return result.encode('utf-8')
292 def render_element(self, element, template_attributes, generated_attributes, qwebcontext, inner=None):
294 # template_attributes: t-* attributes
295 # generated_attributes: generated attributes
296 # qwebcontext: values
297 # inner: optional innerXml
302 for current_node in element.childNodes:
304 g_inner.append(self.render_node(current_node, qwebcontext))
305 except QWebException:
308 template = qwebcontext.get('__template__')
309 raise_qweb_exception(message="Could not render element %r" % element.nodeName, node=element, template=template)
310 name = str(element.nodeName)
311 inner = "".join(g_inner)
312 trim = template_attributes.get("trim", 0)
316 inner = inner.lstrip()
317 elif trim == 'right':
318 inner = inner.rstrip()
320 inner = inner.strip()
323 elif len(inner) or name not in self._void_elements:
324 return "<%s%s>%s</%s>" % tuple(
325 qwebcontext if isinstance(qwebcontext, str) else qwebcontext.encode('utf-8')
326 for qwebcontext in (name, generated_attributes, inner, name)
329 return "<%s%s/>" % (name, generated_attributes)
332 def render_att_att(self, element, attribute_name, attribute_value, qwebcontext):
333 if attribute_name.startswith("t-attf-"):
334 att, val = attribute_name[7:], self.eval_format(attribute_value, qwebcontext)
335 elif attribute_name.startswith("t-att-"):
336 att, val = attribute_name[6:], self.eval(attribute_value, qwebcontext)
337 if isinstance(val, unicode):
338 val = val.encode("utf8")
340 att, val = self.eval_object(attribute_value, qwebcontext)
344 def render_tag_raw(self, element, template_attributes, generated_attributes, qwebcontext):
345 inner = self.eval_str(template_attributes["raw"], qwebcontext)
346 return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
348 def render_tag_esc(self, element, template_attributes, generated_attributes, qwebcontext):
349 options = json.loads(template_attributes.get('esc-options') or '{}')
350 widget = self.get_widget_for(options.get('widget', ''))
351 inner = widget.format(template_attributes['esc'], options, qwebcontext)
352 return self.render_element(element, template_attributes, generated_attributes, qwebcontext, inner)
354 def render_tag_foreach(self, element, template_attributes, generated_attributes, qwebcontext):
355 expr = template_attributes["foreach"]
356 enum = self.eval_object(expr, qwebcontext)
358 template = qwebcontext.get('__template__')
359 raise QWebException("foreach enumerator %r is not defined while rendering template %r" % (expr, template), template=template)
361 varname = template_attributes['as'].replace('.', '_')
362 copy_qwebcontext = qwebcontext.copy()
364 if isinstance(enum, collections.Sized):
366 copy_qwebcontext["%s_size" % varname] = size
367 copy_qwebcontext["%s_all" % varname] = enum
369 for index, item in enumerate(enum):
370 copy_qwebcontext.update({
372 '%s_value' % varname: item,
373 '%s_index' % varname: index,
374 '%s_first' % varname: index == 0,
375 '%s_last' % varname: index + 1 == size,
378 copy_qwebcontext.update({
379 '%s_parity' % varname: 'odd',
380 '%s_even' % varname: False,
381 '%s_odd' % varname: True,
384 copy_qwebcontext.update({
385 '%s_parity' % varname: 'even',
386 '%s_even' % varname: True,
387 '%s_odd' % varname: False,
389 ru.append(self.render_element(element, template_attributes, generated_attributes, copy_qwebcontext))
392 def render_tag_if(self, element, template_attributes, generated_attributes, qwebcontext):
393 if self.eval_bool(template_attributes["if"], qwebcontext):
394 return self.render_element(element, template_attributes, generated_attributes, qwebcontext)
397 def render_tag_call(self, element, template_attributes, generated_attributes, qwebcontext):
398 d = qwebcontext.copy()
399 d[0] = self.render_element(element, template_attributes, generated_attributes, d)
400 cr = d.get('request') and d['request'].cr or None
401 uid = d.get('request') and d['request'].uid or None
403 template = self.eval_format(template_attributes["call"], d)
405 template = int(template)
408 return self.render(cr, uid, template, d)
410 def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext):
411 """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
412 name = template_attributes['call-assets']
414 # Backward compatibility hack for manifest usage
415 qwebcontext['manifest_list'] = openerp.addons.web.controllers.main.manifest_list
417 d = qwebcontext.copy()
418 d.context['inherit_branding'] = False
419 content = self.render_tag_call(
420 element, {'call': name}, generated_attributes, d)
421 bundle = AssetsBundle(name, html=content)
422 css = self.get_attr_bool(template_attributes.get('css'), default=True)
423 js = self.get_attr_bool(template_attributes.get('js'), default=True)
424 return bundle.to_html(css=css, js=js, debug=bool(qwebcontext.get('debug')))
426 def render_tag_set(self, element, template_attributes, generated_attributes, qwebcontext):
427 if "value" in template_attributes:
428 qwebcontext[template_attributes["set"]] = self.eval_object(template_attributes["value"], qwebcontext)
429 elif "valuef" in template_attributes:
430 qwebcontext[template_attributes["set"]] = self.eval_format(template_attributes["valuef"], qwebcontext)
432 qwebcontext[template_attributes["set"]] = self.render_element(element, template_attributes, generated_attributes, qwebcontext)
435 def render_tag_field(self, element, template_attributes, generated_attributes, qwebcontext):
436 """ eg: <span t-record="browse_record(res.partner, 1)" t-field="phone">+1 555 555 8069</span>"""
437 node_name = element.nodeName
438 assert node_name not in ("table", "tbody", "thead", "tfoot", "tr", "td",
439 "li", "ul", "ol", "dl", "dt", "dd"),\
440 "RTE widgets do not work correctly on %r elements" % node_name
441 assert node_name != 't',\
442 "t-field can not be used on a t element, provide an actual HTML node"
444 record, field_name = template_attributes["field"].rsplit('.', 1)
445 record = self.eval_object(record, qwebcontext)
447 column = record._model._all_columns[field_name].column
448 options = json.loads(template_attributes.get('field-options') or '{}')
449 field_type = get_field_type(column, options)
451 converter = self.get_converter_for(field_type)
453 return converter.to_html(qwebcontext.cr, qwebcontext.uid, field_name, record, options,
454 element, template_attributes, generated_attributes, qwebcontext, context=qwebcontext.context)
456 def get_converter_for(self, field_type):
457 return self.pool.get('ir.qweb.field.' + field_type, self.pool['ir.qweb.field'])
459 def get_widget_for(self, widget):
460 return self.pool.get('ir.qweb.widget.' + widget, self.pool['ir.qweb.widget'])
462 def get_attr_bool(self, attr, default=False):
465 if attr in ('false', '0'):
467 elif attr in ('true', '1'):
471 #--------------------------------------------------------------------
472 # QWeb Fields converters
473 #--------------------------------------------------------------------
475 class FieldConverter(osv.AbstractModel):
476 """ Used to convert a t-field specification into an output HTML field.
478 :meth:`~.to_html` is the entry point of this conversion from QWeb, it:
480 * converts the record value to html using :meth:`~.record_to_html`
481 * generates the metadata attributes (``data-oe-``) to set on the root
483 * generates the root result node itself through :meth:`~.render_element`
485 _name = 'ir.qweb.field'
487 def attributes(self, cr, uid, field_name, record, options,
488 source_element, g_att, t_att, qweb_context,
491 Generates the metadata attributes (prefixed by ``data-oe-`` for the
492 root node of the field conversion. Attribute values are escaped by the
493 parent using ``werkzeug.utils.escape``.
495 The default attributes are:
497 * ``model``, the name of the record's model
498 * ``id`` the id of the record to which the field belongs
499 * ``field`` the name of the converted field
500 * ``type`` the logical field type (widget, may not match the column's
501 ``type``, may not be any _column subclass name)
502 * ``translate``, a boolean flag (``0`` or ``1``) denoting whether the
503 column is translatable
504 * ``expression``, the original expression
506 :returns: iterable of (attribute name, attribute value) pairs.
508 column = record._model._all_columns[field_name].column
509 field_type = get_field_type(column, options)
511 ('data-oe-model', record._model._name),
512 ('data-oe-id', record.id),
513 ('data-oe-field', field_name),
514 ('data-oe-type', field_type),
515 ('data-oe-expression', t_att['field']),
518 def value_to_html(self, cr, uid, value, column, options=None, context=None):
519 """ Converts a single value to its HTML version/output
521 if not value: return ''
524 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
525 """ Converts the specified field of the browse_record ``record`` to
528 return self.value_to_html(
529 cr, uid, record[field_name], column, options=options, context=context)
531 def to_html(self, cr, uid, field_name, record, options,
532 source_element, t_att, g_att, qweb_context, context=None):
533 """ Converts a ``t-field`` to its HTML output. A ``t-field`` may be
534 extended by a ``t-field-options``, which is a JSON-serialized mapping
535 of configuration values.
537 A default configuration key is ``widget`` which can override the
538 field's own ``_type``.
541 content = self.record_to_html(
542 cr, uid, field_name, record,
543 record._model._all_columns[field_name].column,
544 options, context=context)
545 if options.get('html-escape', True):
546 content = werkzeug.utils.escape(content)
547 elif hasattr(content, '__html__'):
548 content = content.__html__()
550 _logger.warning("Could not get field %s for model %s",
551 field_name, record._model._name, exc_info=True)
554 if context and context.get('inherit_branding'):
555 # add branding attributes
557 ' %s="%s"' % (name, werkzeug.utils.escape(value))
558 for name, value in self.attributes(
559 cr, uid, field_name, record, options,
560 source_element, g_att, t_att, qweb_context)
563 return self.render_element(cr, uid, source_element, t_att, g_att,
564 qweb_context, content)
566 def qweb_object(self):
567 return self.pool['ir.qweb']
569 def render_element(self, cr, uid, source_element, t_att, g_att,
570 qweb_context, content):
571 """ Final rendering hook, by default just calls ir.qweb's ``render_element``
573 return self.qweb_object().render_element(
574 source_element, t_att, g_att, qweb_context, content or '')
576 def user_lang(self, cr, uid, context):
578 Fetches the res.lang object corresponding to the language code stored
579 in the user's context. Fallbacks to en_US if no lang is present in the
580 context *or the language code is not valid*.
582 :returns: res.lang browse_record
584 if context is None: context = {}
586 lang_code = context.get('lang') or 'en_US'
587 Lang = self.pool['res.lang']
589 lang_ids = Lang.search(cr, uid, [('code', '=', lang_code)], context=context) \
590 or Lang.search(cr, uid, [('code', '=', 'en_US')], context=context)
592 return Lang.browse(cr, uid, lang_ids[0], context=context)
594 class FloatConverter(osv.AbstractModel):
595 _name = 'ir.qweb.field.float'
596 _inherit = 'ir.qweb.field'
598 def precision(self, cr, uid, column, options=None, context=None):
599 _, precision = column.digits or (None, None)
602 def value_to_html(self, cr, uid, value, column, options=None, context=None):
605 precision = self.precision(cr, uid, column, options=options, context=context)
606 fmt = '%f' if precision is None else '%.{precision}f'
608 lang_code = context.get('lang') or 'en_US'
609 lang = self.pool['res.lang']
610 formatted = lang.format(cr, uid, [lang_code], fmt.format(precision=precision), value, grouping=True)
612 # %f does not strip trailing zeroes. %g does but its precision causes
613 # it to switch to scientific notation starting at a million *and* to
614 # strip decimals. So use %f and if no precision was specified manually
616 if precision is None:
617 formatted = re.sub(r'(?:(0|\d+?)0+)$', r'\1', formatted)
620 class DateConverter(osv.AbstractModel):
621 _name = 'ir.qweb.field.date'
622 _inherit = 'ir.qweb.field'
624 def value_to_html(self, cr, uid, value, column, options=None, context=None):
625 if not value: return ''
626 lang = self.user_lang(cr, uid, context=context)
627 locale = babel.Locale.parse(lang.code)
629 if isinstance(value, basestring):
630 value = datetime.datetime.strptime(
631 value, openerp.tools.DEFAULT_SERVER_DATE_FORMAT)
633 if options and 'format' in options:
634 pattern = options['format']
636 strftime_pattern = lang.date_format
637 pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
639 return babel.dates.format_date(
640 value, format=pattern,
643 class DateTimeConverter(osv.AbstractModel):
644 _name = 'ir.qweb.field.datetime'
645 _inherit = 'ir.qweb.field'
647 def value_to_html(self, cr, uid, value, column, options=None, context=None):
648 if not value: return ''
649 lang = self.user_lang(cr, uid, context=context)
650 locale = babel.Locale.parse(lang.code)
652 if isinstance(value, basestring):
653 value = datetime.datetime.strptime(
654 value, openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT)
655 value = fields.datetime.context_timestamp(
656 cr, uid, timestamp=value, context=context)
658 if options and 'format' in options:
659 pattern = options['format']
661 strftime_pattern = (u"%s %s" % (lang.date_format, lang.time_format))
662 pattern = openerp.tools.posix_to_ldml(strftime_pattern, locale=locale)
664 if options and options.get('hide_seconds'):
665 pattern = pattern.replace(":ss", "").replace(":s", "")
667 return babel.dates.format_datetime(value, format=pattern, locale=locale)
669 class TextConverter(osv.AbstractModel):
670 _name = 'ir.qweb.field.text'
671 _inherit = 'ir.qweb.field'
673 def value_to_html(self, cr, uid, value, column, options=None, context=None):
675 Escapes the value and converts newlines to br. This is bullshit.
677 if not value: return ''
679 return nl2br(value, options=options)
681 class SelectionConverter(osv.AbstractModel):
682 _name = 'ir.qweb.field.selection'
683 _inherit = 'ir.qweb.field'
685 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
686 value = record[field_name]
687 if not value: return ''
688 selection = dict(fields.selection.reify(
689 cr, uid, record._model, column))
690 return self.value_to_html(
691 cr, uid, selection[value], column, options=options)
693 class ManyToOneConverter(osv.AbstractModel):
694 _name = 'ir.qweb.field.many2one'
695 _inherit = 'ir.qweb.field'
697 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
698 [read] = record.read([field_name])
699 if not read[field_name]: return ''
700 _, value = read[field_name]
701 return nl2br(value, options=options)
703 class HTMLConverter(osv.AbstractModel):
704 _name = 'ir.qweb.field.html'
705 _inherit = 'ir.qweb.field'
707 def value_to_html(self, cr, uid, value, column, options=None, context=None):
708 return HTMLSafe(value or '')
710 class ImageConverter(osv.AbstractModel):
711 """ ``image`` widget rendering, inserts a data:uri-using image tag in the
712 document. May be overridden by e.g. the website module to generate links
715 .. todo:: what happens if different output need different converters? e.g.
716 reports may need embedded images or FS links whereas website
719 _name = 'ir.qweb.field.image'
720 _inherit = 'ir.qweb.field'
722 def value_to_html(self, cr, uid, value, column, options=None, context=None):
724 image = Image.open(cStringIO.StringIO(value.decode('base64')))
727 raise ValueError("Non-image binary fields can not be converted to HTML")
728 except: # image.verify() throws "suitable exceptions", I have no idea what they are
729 raise ValueError("Invalid image content")
731 return HTMLSafe('<img src="data:%s;base64,%s">' % (Image.MIME[image.format], value))
733 class MonetaryConverter(osv.AbstractModel):
734 """ ``monetary`` converter, has a mandatory option
735 ``display_currency``.
737 The currency is used for formatting *and rounding* of the float value. It
738 is assumed that the linked res_currency has a non-empty rounding value and
739 res.currency's ``round`` method is used to perform rounding.
741 .. note:: the monetary converter internally adds the qweb context to its
742 options mapping, so that the context is available to callees.
743 It's set under the ``_qweb_context`` key.
745 _name = 'ir.qweb.field.monetary'
746 _inherit = 'ir.qweb.field'
748 def to_html(self, cr, uid, field_name, record, options,
749 source_element, t_att, g_att, qweb_context, context=None):
750 options['_qweb_context'] = qweb_context
751 return super(MonetaryConverter, self).to_html(
752 cr, uid, field_name, record, options,
753 source_element, t_att, g_att, qweb_context, context=context)
755 def record_to_html(self, cr, uid, field_name, record, column, options, context=None):
758 Currency = self.pool['res.currency']
759 display = self.display_currency(cr, uid, options)
761 # lang.format mandates a sprintf-style format. These formats are non-
762 # minimal (they have a default fixed precision instead), and
763 # lang.format will not set one by default. currency.round will not
764 # provide one either. So we need to generate a precision value
765 # (integer > 0) from the currency's rounding (a float generally < 1.0).
767 # The log10 of the rounding should be the number of digits involved if
768 # negative, if positive clamp to 0 digits and call it a day.
769 # nb: int() ~ floor(), we want nearest rounding instead
770 precision = int(round(math.log10(display.rounding)))
771 fmt = "%.{0}f".format(-precision if precision < 0 else 0)
773 lang_code = context.get('lang') or 'en_US'
774 lang = self.pool['res.lang']
775 formatted_amount = lang.format(cr, uid, [lang_code],
776 fmt, Currency.round(cr, uid, display, record[field_name]),
777 grouping=True, monetary=True)
780 if display.position == 'before':
785 return HTMLSafe(u'{pre}<span class="oe_currency_value">{0}</span>{post}'.format(
789 symbol=display.symbol,
792 def display_currency(self, cr, uid, options):
793 return self.qweb_object().eval_object(
794 options['display_currency'], options['_qweb_context'])
797 ('year', 3600 * 24 * 365),
798 ('month', 3600 * 24 * 30),
799 ('week', 3600 * 24 * 7),
805 class DurationConverter(osv.AbstractModel):
806 """ ``duration`` converter, to display integral or fractional values as
807 human-readable time spans (e.g. 1.5 as "1 hour 30 minutes").
809 Can be used on any numerical field.
811 Has a mandatory option ``unit`` which can be one of ``second``, ``minute``,
812 ``hour``, ``day``, ``week`` or ``year``, used to interpret the numerical
813 field value before converting it.
815 Sub-second values will be ignored.
817 _name = 'ir.qweb.field.duration'
818 _inherit = 'ir.qweb.field'
820 def value_to_html(self, cr, uid, value, column, options=None, context=None):
821 units = dict(TIMEDELTA_UNITS)
823 raise ValueError(_("Durations can't be negative"))
824 if not options or options.get('unit') not in units:
825 raise ValueError(_("A unit must be provided to duration widgets"))
827 locale = babel.Locale.parse(
828 self.user_lang(cr, uid, context=context).code)
829 factor = units[options['unit']]
833 for unit, secs_per_unit in TIMEDELTA_UNITS:
834 v, r = divmod(r, secs_per_unit)
836 section = babel.dates.format_timedelta(
837 v*secs_per_unit, threshold=1, locale=locale)
839 sections.append(section)
840 return ' '.join(sections)
843 class RelativeDatetimeConverter(osv.AbstractModel):
844 _name = 'ir.qweb.field.relative'
845 _inherit = 'ir.qweb.field'
847 def value_to_html(self, cr, uid, value, column, options=None, context=None):
848 parse_format = openerp.tools.DEFAULT_SERVER_DATETIME_FORMAT
849 locale = babel.Locale.parse(
850 self.user_lang(cr, uid, context=context).code)
852 if isinstance(value, basestring):
853 value = datetime.datetime.strptime(value, parse_format)
855 # value should be a naive datetime in UTC. So is fields.datetime.now()
856 reference = datetime.datetime.strptime(column.now(), parse_format)
858 return babel.dates.format_timedelta(
859 value - reference, add_direction=True, locale=locale)
861 class Contact(orm.AbstractModel):
862 _name = 'ir.qweb.field.contact'
863 _inherit = 'ir.qweb.field.many2one'
865 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
868 opf = options.get('fields') or ["name", "address", "phone", "mobile", "fax", "email"]
870 if not getattr(record, field_name):
873 id = getattr(record, field_name).id
874 field_browse = self.pool[column._obj].browse(cr, openerp.SUPERUSER_ID, id, context={"show_address": True})
875 value = field_browse.name_get()[0][1]
878 'name': value.split("\n")[0],
879 'address': werkzeug.utils.escape("\n".join(value.split("\n")[1:])),
880 'phone': field_browse.phone,
881 'mobile': field_browse.mobile,
882 'fax': field_browse.fax,
883 'city': field_browse.city,
884 'country_id': field_browse.country_id and field_browse.country_id.name_get()[0][1],
885 'website': field_browse.website,
886 'email': field_browse.email,
888 'object': field_browse,
892 html = self.pool["ir.ui.view"].render(cr, uid, "base.contact", val, engine='ir.qweb', context=context).decode('utf8')
894 return HTMLSafe(html)
896 class QwebView(orm.AbstractModel):
897 _name = 'ir.qweb.field.qweb'
898 _inherit = 'ir.qweb.field.many2one'
900 def record_to_html(self, cr, uid, field_name, record, column, options=None, context=None):
901 if not getattr(record, field_name):
904 view = getattr(record, field_name)
906 if view._model._name != "ir.ui.view":
907 _logger.warning("%s.%s must be a 'ir.ui.view' model." % (record, field_name))
910 ctx = (context or {}).copy()
911 ctx['object'] = record
912 html = view.render(ctx, engine='ir.qweb', context=ctx).decode('utf8')
914 return HTMLSafe(html)
916 class QwebWidget(osv.AbstractModel):
917 _name = 'ir.qweb.widget'
919 def _format(self, inner, options, qwebcontext):
920 return self.pool['ir.qweb'].eval_str(inner, qwebcontext)
922 def format(self, inner, options, qwebcontext):
923 return werkzeug.utils.escape(self._format(inner, options, qwebcontext))
925 class QwebWidgetMonetary(osv.AbstractModel):
926 _name = 'ir.qweb.widget.monetary'
927 _inherit = 'ir.qweb.widget'
929 def _format(self, inner, options, qwebcontext):
930 inner = self.pool['ir.qweb'].eval(inner, qwebcontext)
931 display = self.pool['ir.qweb'].eval_object(options['display_currency'], qwebcontext)
932 precision = int(round(math.log10(display.rounding)))
933 fmt = "%.{0}f".format(-precision if precision < 0 else 0)
934 lang_code = qwebcontext.context.get('lang') or 'en_US'
935 formatted_amount = self.pool['res.lang'].format(
936 qwebcontext.cr, qwebcontext.uid, [lang_code], fmt, inner, grouping=True, monetary=True
939 if display.position == 'before':
944 return u'{pre}{0}{post}'.format(
945 formatted_amount, pre=pre, post=post
946 ).format(symbol=display.symbol,)
948 class HTMLSafe(object):
949 """ HTMLSafe string wrapper, Werkzeug's escape() has special handling for
950 objects with a ``__html__`` methods but AFAIK does not provide any such
953 Wrapping a string in HTML will prevent its escaping
955 __slots__ = ['string']
956 def __init__(self, string):
962 if isinstance(s, unicode):
963 return s.encode('utf-8')
965 def __unicode__(self):
967 if isinstance(s, str):
968 return s.decode('utf-8')
971 def nl2br(string, options=None):
972 """ Converts newlines to HTML linebreaks in ``string``. Automatically
973 escapes content unless options['html-escape'] is set to False, and returns
974 the result wrapped in an HTMLSafe object.
980 if options is None: options = {}
982 if options.get('html-escape', True):
983 string = werkzeug.utils.escape(string)
984 return HTMLSafe(string.replace('\n', '<br>\n'))
986 def get_field_type(column, options):
987 """ Gets a t-field's effective type from the field's column and its options
989 return options.get('widget', column._type)
991 class AssetsBundle(object):
992 cache = openerp.tools.lru.LRU(32)
993 rx_css_import = re.compile("(@import[^;{]+;?)", re.M)
995 def __init__(self, xmlid, html=None, debug=False):
998 self.javascripts = []
999 self.stylesheets = []
1001 self._checksum = None
1005 def parse(self, html):
1006 fragments = lxml.html.fragments_fromstring(html)
1007 for el in fragments:
1008 if isinstance(el, basestring):
1009 self.remains.append(el)
1010 elif isinstance(el, lxml.html.HtmlElement):
1012 href = el.get('href')
1013 if el.tag == 'style':
1014 self.stylesheets.append(StylesheetAsset(source=el.text))
1015 elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href):
1016 self.stylesheets.append(StylesheetAsset(url=href))
1017 elif el.tag == 'script' and not src:
1018 self.javascripts.append(JavascriptAsset(source=el.text))
1019 elif el.tag == 'script' and self.can_aggregate(src):
1020 self.javascripts.append(JavascriptAsset(url=src))
1022 self.remains.append(lxml.html.tostring(el))
1025 self.remains.append(lxml.html.tostring(el))
1027 # notYETimplementederror
1028 raise NotImplementedError
1030 def can_aggregate(self, url):
1031 return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js'))
1033 def to_html(self, sep='\n ', css=True, js=True, debug=False):
1037 for style in self.stylesheets:
1038 response.append(style.to_html())
1040 for jscript in self.javascripts:
1041 response.append(jscript.to_html())
1043 if css and self.stylesheets:
1044 response.append('<link href="/web/css/%s" rel="stylesheet"/>' % self.xmlid)
1045 if js and self.javascripts:
1046 response.append('<script type="text/javascript" src="/web/js/%s"></script>' % self.xmlid)
1047 response.extend(self.remains)
1048 return sep + sep.join(response)
1050 @openerp.tools.func.lazy_property
1051 def last_modified(self):
1052 return max(itertools.chain(
1053 (asset.last_modified for asset in self.javascripts),
1054 (asset.last_modified for asset in self.stylesheets),
1055 [datetime.datetime(1970, 1, 1)],
1058 @openerp.tools.func.lazy_property
1060 checksum = hashlib.new('sha1')
1061 for asset in itertools.chain(self.javascripts, self.stylesheets):
1062 checksum.update(asset.content.encode("utf-8"))
1063 return checksum.hexdigest()
1066 key = 'js_' + self.checksum
1067 if key not in self.cache:
1068 content =';\n'.join(asset.minify() for asset in self.javascripts)
1069 self.cache[key] = content
1071 return "/*\n%s\n*/\n" % '\n'.join(
1072 [asset.url for asset in self.javascripts if asset.url]) + self.cache[key]
1073 return self.cache[key]
1076 key = 'css_' + self.checksum
1077 if key not in self.cache:
1078 content = '\n'.join(asset.minify() for asset in self.stylesheets)
1079 # move up all @import rules to the top
1082 matches.append(matchobj.group(0))
1085 content = re.sub(self.rx_css_import, push, content)
1087 matches.append(content)
1088 content = u'\n'.join(matches)
1089 self.cache[key] = content
1091 return "/*\n%s\n*/\n" % '\n'.join(
1092 [asset.url for asset in self.javascripts if asset.url]) + self.cache[key]
1093 return self.cache[key]
1095 class WebAsset(object):
1096 def __init__(self, source=None, url=None):
1097 self.source = source
1099 self._irattach = None
1100 self._content = None
1101 self.filename = None
1102 self.last_modified = None
1104 self.last_modified = datetime.datetime(1970, 1, 1)
1106 module = filter(None, self.url.split('/'))[0]
1108 # Test url against modules static assets
1109 mpath = openerp.http.addons_manifest[module]['addons_path']
1110 self.filename = mpath + self.url.replace('/', os.path.sep)
1111 self.last_modified = datetime.datetime.fromtimestamp(os.path.getmtime(self.filename))
1114 # Test url against ir.attachments
1115 domain = [('type', '=', 'binary'), ('url', '=', self.url)]
1116 attach = request.registry['ir.attachment'].search_read(request.cr, SUPERUSER_ID, domain, ['__last_update', 'datas', 'mimetype'], context=request.context)
1117 self._irattach = attach[0]
1118 server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT
1120 self.last_modified = datetime.datetime.strptime(attach[0]['__last_update'], server_format + '.%f')
1122 self.last_modified = datetime.datetime.strptime(attach[0]['__last_update'], server_format)
1124 raise KeyError("Could not find asset '%s' for '%s' addon" % (self.url, module))
1126 @openerp.tools.func.lazy_property
1131 return self._irattach['datas'].decode('base64')
1132 return self.get_content()
1134 def get_content(self):
1135 with open(self.filename, 'rb') as fp:
1136 return fp.read().decode('utf-8')
1141 class JavascriptAsset(WebAsset):
1143 return rjsmin(self.content)
1147 return '<script type="text/javascript" src="%s"></script>' % self.url
1149 return '<script type="text/javascript" charset="utf-8">%s</script>' % self.source
1151 class StylesheetAsset(WebAsset):
1152 rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U)
1153 rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U)
1154 rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
1156 def _get_content(self):
1157 with open(self.filename, 'rb') as fp:
1158 firstline = fp.readline()
1159 m = re.match(r'@charset "([^"]+)";', firstline)
1161 encoding = m.group(1)
1164 # "reinject" first line as it's not @charset
1167 return fp.read().decode(encoding)
1169 def get_content(self):
1170 content = self._get_content()
1172 web_dir = os.path.dirname(self.url)
1174 content = self.rx_import.sub(
1175 r"""@import \1%s/""" % (web_dir,),
1179 content = self.rx_url.sub(
1180 r"url(\1%s/" % (web_dir,),
1186 # remove existing sourcemaps, make no sense after re-mini
1187 content = self.rx_sourceMap.sub('', self.content)
1189 content = re.sub(r'/\*.*?\*/', '', content, flags=re.S)
1191 content = re.sub(r'\s+', ' ', content)
1192 content = re.sub(r' *([{}]) *', r'\1', content)
1197 return '<link rel="stylesheet" href="%s" type="text/css"/>' % self.url
1199 return '<style type="text/css">%s</style>' % self.source
1202 """ Minify js with a clever regex.
1203 Taken from http://opensource.perlig.de/rjsmin
1204 Apache License, Version 2.0 """
1206 """ Substitution callback """
1207 groups = match.groups()
1213 (groups[4] and '\n') or
1214 (groups[5] and ' ') or
1215 (groups[6] and ' ') or
1216 (groups[7] and ' ') or
1221 r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?'
1222 r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|'
1223 r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]'
1224 r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'
1225 r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
1226 r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*'
1227 r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01'
1228 r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/'
1229 r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]'
1230 r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./'
1231 r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
1232 r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01'
1233 r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#'
1234 r'%-\047)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-'
1235 r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^'
1236 r'\000-#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|'
1237 r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<=-)((?:[\000-\011\0'
1238 r'13\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=-)|(?:[\0'
1239 r'00-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:'
1240 r'(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
1241 r']*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script