summaryrefslogtreecommitdiff
path: root/addons/web_editor/models/ir_qweb.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web_editor/models/ir_qweb.py')
-rw-r--r--addons/web_editor/models/ir_qweb.py618
1 files changed, 618 insertions, 0 deletions
diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py
new file mode 100644
index 00000000..05b274c9
--- /dev/null
+++ b/addons/web_editor/models/ir_qweb.py
@@ -0,0 +1,618 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+"""
+Web_editor-context rendering needs to add some metadata to rendered and allow to edit fields,
+as well as render a few fields differently.
+
+Also, adds methods to convert values back to Odoo models.
+"""
+
+import ast
+import babel
+import base64
+import io
+import itertools
+import json
+import logging
+import os
+import re
+import hashlib
+from datetime import datetime
+
+import pytz
+import requests
+from datetime import datetime
+from lxml import etree, html
+from PIL import Image as I
+from werkzeug import urls
+
+import odoo.modules
+
+from odoo import api, models, fields
+from odoo.tools import ustr, posix_to_ldml, pycompat
+from odoo.tools import html_escape as escape
+from odoo.tools.misc import get_lang, babel_locale_parse
+from odoo.addons.base.models import ir_qweb
+
+REMOTE_CONNECTION_TIMEOUT = 2.5
+
+logger = logging.getLogger(__name__)
+
+
+class QWeb(models.AbstractModel):
+ """ QWeb object for rendering editor stuff
+ """
+ _inherit = 'ir.qweb'
+
+ # compile directives
+
+ def _compile_node(self, el, options):
+ snippet_key = options.get('snippet-key')
+ if snippet_key == options['template'] \
+ or options.get('snippet-sub-call-key') == options['template']:
+ # Get the path of element to only consider the first node of the
+ # snippet template content (ignoring all ancestors t elements which
+ # are not t-call ones)
+ nb_real_elements_in_hierarchy = 0
+ node = el
+ while node is not None and nb_real_elements_in_hierarchy < 2:
+ if node.tag != 't' or 't-call' in node.attrib:
+ nb_real_elements_in_hierarchy += 1
+ node = node.getparent()
+ if nb_real_elements_in_hierarchy == 1:
+ # The first node might be a call to a sub template
+ sub_call = el.get('t-call')
+ if sub_call:
+ el.set('t-call-options', f"{{'snippet-key': '{snippet_key}', 'snippet-sub-call-key': '{sub_call}'}}")
+ # If it already has a data-snippet it is a saved snippet.
+ # Do not override it.
+ elif 'data-snippet' not in el.attrib:
+ el.attrib['data-snippet'] = snippet_key.split('.', 1)[-1]
+
+ return super()._compile_node(el, options)
+
+ def _compile_directive_snippet(self, el, options):
+ key = el.attrib.pop('t-snippet')
+ el.set('t-call', key)
+ el.set('t-call-options', "{'snippet-key': '" + key + "'}")
+ View = self.env['ir.ui.view'].sudo()
+ view_id = View.get_view_id(key)
+ name = View.browse(view_id).name
+ thumbnail = el.attrib.pop('t-thumbnail', "oe-thumbnail")
+ div = u'<div name="%s" data-oe-type="snippet" data-oe-thumbnail="%s" data-oe-snippet-id="%s" data-oe-keywords="%s">' % (
+ escape(pycompat.to_text(name)),
+ escape(pycompat.to_text(thumbnail)),
+ escape(pycompat.to_text(view_id)),
+ escape(pycompat.to_text(el.findtext('keywords')))
+ )
+ return [self._append(ast.Str(div))] + self._compile_node(el, options) + [self._append(ast.Str(u'</div>'))]
+
+ def _compile_directive_snippet_call(self, el, options):
+ key = el.attrib.pop('t-snippet-call')
+ el.set('t-call', key)
+ el.set('t-call-options', "{'snippet-key': '" + key + "'}")
+ return self._compile_node(el, options)
+
+ def _compile_directive_install(self, el, options):
+ if self.user_has_groups('base.group_system'):
+ module = self.env['ir.module.module'].search([('name', '=', el.attrib.get('t-install'))])
+ if not module or module.state == 'installed':
+ return []
+ name = el.attrib.get('string') or 'Snippet'
+ thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail')
+ div = u'<div name="%s" data-oe-type="snippet" data-module-id="%s" data-oe-thumbnail="%s"><section/></div>' % (
+ escape(pycompat.to_text(name)),
+ module.id,
+ escape(pycompat.to_text(thumbnail))
+ )
+ return [self._append(ast.Str(div))]
+ else:
+ return []
+
+ def _compile_directive_tag(self, el, options):
+ if el.get('t-placeholder'):
+ el.set('t-att-placeholder', el.attrib.pop('t-placeholder'))
+ return super(QWeb, self)._compile_directive_tag(el, options)
+
+ # order and ignore
+
+ def _directives_eval_order(self):
+ directives = super(QWeb, self)._directives_eval_order()
+ directives.insert(directives.index('call'), 'snippet')
+ directives.insert(directives.index('call'), 'snippet-call')
+ directives.insert(directives.index('call'), 'install')
+ return directives
+
+
+#------------------------------------------------------
+# QWeb fields
+#------------------------------------------------------
+
+
+class Field(models.AbstractModel):
+ _name = 'ir.qweb.field'
+ _description = 'Qweb Field'
+ _inherit = 'ir.qweb.field'
+
+ @api.model
+ def attributes(self, record, field_name, options, values):
+ attrs = super(Field, self).attributes(record, field_name, options, values)
+ field = record._fields[field_name]
+
+ placeholder = options.get('placeholder') or getattr(field, 'placeholder', None)
+ if placeholder:
+ attrs['placeholder'] = placeholder
+
+ if options['translate'] and field.type in ('char', 'text'):
+ name = "%s,%s" % (record._name, field_name)
+ domain = [('name', '=', name), ('res_id', '=', record.id), ('type', '=', 'model'), ('lang', '=', options.get('lang'))]
+ translation = record.env['ir.translation'].search(domain, limit=1)
+ attrs['data-oe-translation-state'] = translation and translation.state or 'to_translate'
+
+ return attrs
+
+ def value_from_string(self, value):
+ return value
+
+ @api.model
+ def from_html(self, model, field, element):
+ return self.value_from_string(element.text_content().strip())
+
+
+class Integer(models.AbstractModel):
+ _name = 'ir.qweb.field.integer'
+ _description = 'Qweb Field Integer'
+ _inherit = 'ir.qweb.field.integer'
+
+ value_from_string = int
+
+
+class Float(models.AbstractModel):
+ _name = 'ir.qweb.field.float'
+ _description = 'Qweb Field Float'
+ _inherit = 'ir.qweb.field.float'
+
+ @api.model
+ def from_html(self, model, field, element):
+ lang = self.user_lang()
+ value = element.text_content().strip()
+ return float(value.replace(lang.thousands_sep, '')
+ .replace(lang.decimal_point, '.'))
+
+
+class ManyToOne(models.AbstractModel):
+ _name = 'ir.qweb.field.many2one'
+ _description = 'Qweb Field Many to One'
+ _inherit = 'ir.qweb.field.many2one'
+
+ @api.model
+ def attributes(self, record, field_name, options, values):
+ attrs = super(ManyToOne, self).attributes(record, field_name, options, values)
+ if options.get('inherit_branding'):
+ many2one = getattr(record, field_name)
+ if many2one:
+ attrs['data-oe-many2one-id'] = many2one.id
+ attrs['data-oe-many2one-model'] = many2one._name
+ return attrs
+
+ @api.model
+ def from_html(self, model, field, element):
+ Model = self.env[element.get('data-oe-model')]
+ id = int(element.get('data-oe-id'))
+ M2O = self.env[field.comodel_name]
+ field_name = element.get('data-oe-field')
+ many2one_id = int(element.get('data-oe-many2one-id'))
+ record = many2one_id and M2O.browse(many2one_id)
+ if record and record.exists():
+ # save the new id of the many2one
+ Model.browse(id).write({field_name: many2one_id})
+
+ # not necessary, but might as well be explicit about it
+ return None
+
+
+class Contact(models.AbstractModel):
+ _name = 'ir.qweb.field.contact'
+ _description = 'Qweb Field Contact'
+ _inherit = 'ir.qweb.field.contact'
+
+ @api.model
+ def attributes(self, record, field_name, options, values):
+ attrs = super(Contact, self).attributes(record, field_name, options, values)
+ if options.get('inherit_branding'):
+ options.pop('template_options') # remove options not specific to this widget
+ attrs['data-oe-contact-options'] = json.dumps(options)
+ return attrs
+
+ # helper to call the rendering of contact field
+ @api.model
+ def get_record_to_html(self, ids, options=None):
+ return self.value_to_html(self.env['res.partner'].search([('id', '=', ids[0])]), options=options)
+
+
+class Date(models.AbstractModel):
+ _name = 'ir.qweb.field.date'
+ _description = 'Qweb Field Date'
+ _inherit = 'ir.qweb.field.date'
+
+ @api.model
+ def attributes(self, record, field_name, options, values):
+ attrs = super(Date, self).attributes(record, field_name, options, values)
+ if options.get('inherit_branding'):
+ attrs['data-oe-original'] = record[field_name]
+
+ if record._fields[field_name].type == 'datetime':
+ attrs = self.env['ir.qweb.field.datetime'].attributes(record, field_name, options, values)
+ attrs['data-oe-type'] = 'datetime'
+ return attrs
+
+ lg = self.env['res.lang']._lang_get(self.env.user.lang) or get_lang(self.env)
+ locale = babel_locale_parse(lg.code)
+ babel_format = value_format = posix_to_ldml(lg.date_format, locale=locale)
+
+ if record[field_name]:
+ date = fields.Date.from_string(record[field_name])
+ value_format = pycompat.to_text(babel.dates.format_date(date, format=babel_format, locale=locale))
+
+ attrs['data-oe-original-with-format'] = value_format
+ return attrs
+
+ @api.model
+ def from_html(self, model, field, element):
+ value = element.text_content().strip()
+ if not value:
+ return False
+
+ lg = self.env['res.lang']._lang_get(self.env.user.lang) or get_lang(self.env)
+ date = datetime.strptime(value, lg.date_format)
+ return fields.Date.to_string(date)
+
+
+class DateTime(models.AbstractModel):
+ _name = 'ir.qweb.field.datetime'
+ _description = 'Qweb Field Datetime'
+ _inherit = 'ir.qweb.field.datetime'
+
+ @api.model
+ def attributes(self, record, field_name, options, values):
+ attrs = super(DateTime, self).attributes(record, field_name, options, values)
+
+ if options.get('inherit_branding'):
+ value = record[field_name]
+
+ lg = self.env['res.lang']._lang_get(self.env.user.lang) or get_lang(self.env)
+ locale = babel_locale_parse(lg.code)
+ babel_format = value_format = posix_to_ldml('%s %s' % (lg.date_format, lg.time_format), locale=locale)
+ tz = record.env.context.get('tz') or self.env.user.tz
+
+ if isinstance(value, str):
+ value = fields.Datetime.from_string(value)
+
+ if value:
+ # convert from UTC (server timezone) to user timezone
+ value = fields.Datetime.context_timestamp(self.with_context(tz=tz), timestamp=value)
+ value_format = pycompat.to_text(babel.dates.format_datetime(value, format=babel_format, locale=locale))
+ value = fields.Datetime.to_string(value)
+
+ attrs['data-oe-original'] = value
+ attrs['data-oe-original-with-format'] = value_format
+ attrs['data-oe-original-tz'] = tz
+ return attrs
+
+ @api.model
+ def from_html(self, model, field, element):
+ value = element.text_content().strip()
+ if not value:
+ return False
+
+ # parse from string to datetime
+ lg = self.env['res.lang']._lang_get(self.env.user.lang) or get_lang(self.env)
+ dt = datetime.strptime(value, '%s %s' % (lg.date_format, lg.time_format))
+
+ # convert back from user's timezone to UTC
+ tz_name = element.attrib.get('data-oe-original-tz') or self.env.context.get('tz') or self.env.user.tz
+ if tz_name:
+ try:
+ user_tz = pytz.timezone(tz_name)
+ utc = pytz.utc
+
+ dt = user_tz.localize(dt).astimezone(utc)
+ except Exception:
+ logger.warning(
+ "Failed to convert the value for a field of the model"
+ " %s back from the user's timezone (%s) to UTC",
+ model, tz_name,
+ exc_info=True)
+
+ # format back to string
+ return fields.Datetime.to_string(dt)
+
+
+class Text(models.AbstractModel):
+ _name = 'ir.qweb.field.text'
+ _description = 'Qweb Field Text'
+ _inherit = 'ir.qweb.field.text'
+
+ @api.model
+ def from_html(self, model, field, element):
+ return html_to_text(element)
+
+
+class Selection(models.AbstractModel):
+ _name = 'ir.qweb.field.selection'
+ _description = 'Qweb Field Selection'
+ _inherit = 'ir.qweb.field.selection'
+
+ @api.model
+ def from_html(self, model, field, element):
+ value = element.text_content().strip()
+ selection = field.get_description(self.env)['selection']
+ for k, v in selection:
+ if isinstance(v, str):
+ v = ustr(v)
+ if value == v:
+ return k
+
+ raise ValueError(u"No value found for label %s in selection %s" % (
+ value, selection))
+
+
+class HTML(models.AbstractModel):
+ _name = 'ir.qweb.field.html'
+ _description = 'Qweb Field HTML'
+ _inherit = 'ir.qweb.field.html'
+
+ @api.model
+ def from_html(self, model, field, element):
+ content = []
+ if element.text:
+ content.append(element.text)
+ content.extend(html.tostring(child, encoding='unicode')
+ for child in element.iterchildren(tag=etree.Element))
+ return '\n'.join(content)
+
+
+class Image(models.AbstractModel):
+ """
+ Widget options:
+
+ ``class``
+ set as attribute on the generated <img> tag
+ """
+ _name = 'ir.qweb.field.image'
+ _description = 'Qweb Field Image'
+ _inherit = 'ir.qweb.field.image'
+
+ local_url_re = re.compile(r'^/(?P<module>[^]]+)/static/(?P<rest>.+)$')
+
+ @api.model
+ def from_html(self, model, field, element):
+ if element.find('img') is None:
+ return False
+ url = element.find('img').get('src')
+
+ url_object = urls.url_parse(url)
+ if url_object.path.startswith('/web/image'):
+ fragments = url_object.path.split('/')
+ query = url_object.decode_query()
+ url_id = fragments[3].split('-')[0]
+ # ir.attachment image urls: /web/image/<id>[-<checksum>][/...]
+ if url_id.isdigit():
+ model = 'ir.attachment'
+ oid = url_id
+ field = 'datas'
+ # url of binary field on model: /web/image/<model>/<id>/<field>[/...]
+ else:
+ model = query.get('model', fragments[3])
+ oid = query.get('id', fragments[4])
+ field = query.get('field', fragments[5])
+ item = self.env[model].browse(int(oid))
+ return item[field]
+
+ if self.local_url_re.match(url_object.path):
+ return self.load_local_url(url)
+
+ return self.load_remote_url(url)
+
+ def load_local_url(self, url):
+ match = self.local_url_re.match(urls.url_parse(url).path)
+
+ rest = match.group('rest')
+ for sep in os.sep, os.altsep:
+ if sep and sep != '/':
+ rest.replace(sep, '/')
+
+ path = odoo.modules.get_module_resource(
+ match.group('module'), 'static', *(rest.split('/')))
+
+ if not path:
+ return None
+
+ try:
+ with open(path, 'rb') as f:
+ # force complete image load to ensure it's valid image data
+ image = I.open(f)
+ image.load()
+ f.seek(0)
+ return base64.b64encode(f.read())
+ except Exception:
+ logger.exception("Failed to load local image %r", url)
+ return None
+
+ def load_remote_url(self, url):
+ try:
+ # should probably remove remote URLs entirely:
+ # * in fields, downloading them without blowing up the server is a
+ # challenge
+ # * in views, may trigger mixed content warnings if HTTPS CMS
+ # linking to HTTP images
+ # implement drag & drop image upload to mitigate?
+
+ req = requests.get(url, timeout=REMOTE_CONNECTION_TIMEOUT)
+ # PIL needs a seekable file-like image so wrap result in IO buffer
+ image = I.open(io.BytesIO(req.content))
+ # force a complete load of the image data to validate it
+ image.load()
+ except Exception:
+ logger.exception("Failed to load remote image %r", url)
+ return None
+
+ # don't use original data in case weird stuff was smuggled in, with
+ # luck PIL will remove some of it?
+ out = io.BytesIO()
+ image.save(out, image.format)
+ return base64.b64encode(out.getvalue())
+
+
+class Monetary(models.AbstractModel):
+ _name = 'ir.qweb.field.monetary'
+ _inherit = 'ir.qweb.field.monetary'
+
+ @api.model
+ def from_html(self, model, field, element):
+ lang = self.user_lang()
+
+ value = element.find('span').text.strip()
+
+ return float(value.replace(lang.thousands_sep, '')
+ .replace(lang.decimal_point, '.'))
+
+
+class Duration(models.AbstractModel):
+ _name = 'ir.qweb.field.duration'
+ _description = 'Qweb Field Duration'
+ _inherit = 'ir.qweb.field.duration'
+
+ @api.model
+ def attributes(self, record, field_name, options, values):
+ attrs = super(Duration, self).attributes(record, field_name, options, values)
+ if options.get('inherit_branding'):
+ attrs['data-oe-original'] = record[field_name]
+ return attrs
+
+ @api.model
+ def from_html(self, model, field, element):
+ value = element.text_content().strip()
+
+ # non-localized value
+ return float(value)
+
+
+class RelativeDatetime(models.AbstractModel):
+ _name = 'ir.qweb.field.relative'
+ _description = 'Qweb Field Relative'
+ _inherit = 'ir.qweb.field.relative'
+
+ # get formatting from ir.qweb.field.relative but edition/save from datetime
+
+
+class QwebView(models.AbstractModel):
+ _name = 'ir.qweb.field.qweb'
+ _description = 'Qweb Field qweb'
+ _inherit = 'ir.qweb.field.qweb'
+
+
+def html_to_text(element):
+ """ Converts HTML content with HTML-specified line breaks (br, p, div, ...)
+ in roughly equivalent textual content.
+
+ Used to replace and fixup the roundtripping of text and m2o: when using
+ libxml 2.8.0 (but not 2.9.1) and parsing HTML with lxml.html.fromstring
+ whitespace text nodes (text nodes composed *solely* of whitespace) are
+ stripped out with no recourse, and fundamentally relying on newlines
+ being in the text (e.g. inserted during user edition) is probably poor form
+ anyway.
+
+ -> this utility function collapses whitespace sequences and replaces
+ nodes by roughly corresponding linebreaks
+ * p are pre-and post-fixed by 2 newlines
+ * br are replaced by a single newline
+ * block-level elements not already mentioned are pre- and post-fixed by
+ a single newline
+
+ ought be somewhat similar (but much less high-tech) to aaronsw's html2text.
+ the latter produces full-blown markdown, our text -> html converter only
+ replaces newlines by <br> elements at this point so we're reverting that,
+ and a few more newline-ish elements in case the user tried to add
+ newlines/paragraphs into the text field
+
+ :param element: lxml.html content
+ :returns: corresponding pure-text output
+ """
+
+ # output is a list of str | int. Integers are padding requests (in minimum
+ # number of newlines). When multiple padding requests, fold them into the
+ # biggest one
+ output = []
+ _wrap(element, output)
+
+ # remove any leading or tailing whitespace, replace sequences of
+ # (whitespace)\n(whitespace) by a single newline, where (whitespace) is a
+ # non-newline whitespace in this case
+ return re.sub(
+ r'[ \t\r\f]*\n[ \t\r\f]*',
+ '\n',
+ ''.join(_realize_padding(output)).strip())
+
+_PADDED_BLOCK = set('p h1 h2 h3 h4 h5 h6'.split())
+# https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements minus p
+_MISC_BLOCK = set((
+ 'address article aside audio blockquote canvas dd dl div figcaption figure'
+ ' footer form header hgroup hr ol output pre section tfoot ul video'
+).split())
+
+
+def _collapse_whitespace(text):
+ """ Collapses sequences of whitespace characters in ``text`` to a single
+ space
+ """
+ return re.sub('\s+', ' ', text)
+
+
+def _realize_padding(it):
+ """ Fold and convert padding requests: integers in the output sequence are
+ requests for at least n newlines of padding. Runs thereof can be collapsed
+ into the largest requests and converted to newlines.
+ """
+ padding = 0
+ for item in it:
+ if isinstance(item, int):
+ padding = max(padding, item)
+ continue
+
+ if padding:
+ yield '\n' * padding
+ padding = 0
+
+ yield item
+ # leftover padding irrelevant as the output will be stripped
+
+
+def _wrap(element, output, wrapper=u''):
+ """ Recursively extracts text from ``element`` (via _element_to_text), and
+ wraps it all in ``wrapper``. Extracted text is added to ``output``
+
+ :type wrapper: basestring | int
+ """
+ output.append(wrapper)
+ if element.text:
+ output.append(_collapse_whitespace(element.text))
+ for child in element:
+ _element_to_text(child, output)
+ output.append(wrapper)
+
+
+def _element_to_text(e, output):
+ if e.tag == 'br':
+ output.append(u'\n')
+ elif e.tag in _PADDED_BLOCK:
+ _wrap(e, output, 2)
+ elif e.tag in _MISC_BLOCK:
+ _wrap(e, output, 1)
+ else:
+ # inline
+ _wrap(e, output)
+
+ if e.tail:
+ output.append(_collapse_whitespace(e.tail))