summaryrefslogtreecommitdiff
path: root/addons/web_editor/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_editor/models')
-rw-r--r--addons/web_editor/models/__init__.py12
-rw-r--r--addons/web_editor/models/assets.py278
-rw-r--r--addons/web_editor/models/ir_attachment.py62
-rw-r--r--addons/web_editor/models/ir_http.py26
-rw-r--r--addons/web_editor/models/ir_qweb.py618
-rw-r--r--addons/web_editor/models/ir_translation.py46
-rw-r--r--addons/web_editor/models/ir_ui_view.py382
-rw-r--r--addons/web_editor/models/test_models.py37
8 files changed, 1461 insertions, 0 deletions
diff --git a/addons/web_editor/models/__init__.py b/addons/web_editor/models/__init__.py
new file mode 100644
index 00000000..e9a790f7
--- /dev/null
+++ b/addons/web_editor/models/__init__.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_attachment
+from . import ir_qweb
+from . import ir_ui_view
+from . import ir_http
+from . import ir_translation
+
+from . import assets
+
+from . import test_models
diff --git a/addons/web_editor/models/assets.py b/addons/web_editor/models/assets.py
new file mode 100644
index 00000000..425c5376
--- /dev/null
+++ b/addons/web_editor/models/assets.py
@@ -0,0 +1,278 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import os
+import re
+import uuid
+
+from lxml import etree
+
+from odoo import models
+from odoo.modules.module import get_resource_path, get_module_path
+
+_match_asset_file_url_regex = re.compile("^/(\w+)/(.+?)(\.custom\.(.+))?\.(\w+)$")
+
+
+class Assets(models.AbstractModel):
+ _name = 'web_editor.assets'
+ _description = 'Assets Utils'
+
+ def get_all_custom_attachments(self, urls):
+ """
+ Fetch all the ir.attachment records related to given URLs.
+
+ Params:
+ urls (str[]): list of urls
+
+ Returns:
+ ir.attachment(): attachment records related to the given URLs.
+ """
+ return self._get_custom_attachment(urls, op='in')
+
+ def get_asset_content(self, url, url_info=None, custom_attachments=None):
+ """
+ Fetch the content of an asset (scss / js) file. That content is either
+ the one of the related file on the disk or the one of the corresponding
+ custom ir.attachment record.
+
+ Params:
+ url (str): the URL of the asset (scss / js) file/ir.attachment
+
+ url_info (dict, optional):
+ the related url info (see get_asset_info) (allows to optimize
+ some code which already have the info and do not want this
+ function to re-get it)
+
+ custom_attachments (ir.attachment(), optional):
+ the related custom ir.attachment records the function might need
+ to search into (allows to optimize some code which already have
+ that info and do not want this function to re-get it)
+
+ Returns:
+ utf-8 encoded content of the asset (scss / js)
+ """
+ if url_info is None:
+ url_info = self.get_asset_info(url)
+
+ if url_info["customized"]:
+ # If the file is already customized, the content is found in the
+ # corresponding attachment
+ attachment = None
+ if custom_attachments is None:
+ attachment = self._get_custom_attachment(url)
+ else:
+ attachment = custom_attachments.filtered(lambda r: r.url == url)
+ return attachment and base64.b64decode(attachment.datas) or False
+
+ # If the file is not yet customized, the content is found by reading
+ # the local scss file
+ module = url_info["module"]
+ module_path = get_module_path(module)
+ module_resource_path = get_resource_path(module, url_info["resource_path"])
+ if module_path and module_resource_path:
+ module_path = os.path.join(os.path.normpath(module_path), '') # join ensures the path ends with '/'
+ module_resource_path = os.path.normpath(module_resource_path)
+ if module_resource_path.startswith(module_path):
+ with open(module_resource_path, "rb") as f:
+ return f.read()
+
+ def get_asset_info(self, url):
+ """
+ Return information about an asset (scss / js) file/ir.attachment just by
+ looking at its URL.
+
+ Params:
+ url (str): the url of the asset (scss / js) file/ir.attachment
+
+ Returns:
+ dict:
+ module (str): the original asset's related app
+
+ resource_path (str):
+ the relative path to the original asset from the related app
+
+ customized (bool): whether the asset is a customized one or not
+
+ bundle (str):
+ the name of the bundle the asset customizes (False if this
+ is not a customized asset)
+ """
+ m = _match_asset_file_url_regex.match(url)
+ if not m:
+ return False
+ return {
+ 'module': m.group(1),
+ 'resource_path': "%s.%s" % (m.group(2), m.group(5)),
+ 'customized': bool(m.group(3)),
+ 'bundle': m.group(4) or False
+ }
+
+ def make_custom_asset_file_url(self, url, bundle_xmlid):
+ """
+ Return the customized version of an asset URL, that is the URL the asset
+ would have if it was customized.
+
+ Params:
+ url (str): the original asset's url
+ bundle_xmlid (str): the name of the bundle the asset would customize
+
+ Returns:
+ str: the URL the given asset would have if it was customized in the
+ given bundle
+ """
+ parts = url.rsplit(".", 1)
+ return "%s.custom.%s.%s" % (parts[0], bundle_xmlid, parts[1])
+
+ def reset_asset(self, url, bundle_xmlid):
+ """
+ Delete the potential customizations made to a given (original) asset.
+
+ Params:
+ url (str): the URL of the original asset (scss / js) file
+
+ bundle_xmlid (str):
+ the name of the bundle in which the customizations to delete
+ were made
+ """
+ custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
+
+ # Simply delete the attachement which contains the modified scss/js file
+ # and the xpath view which links it
+ self._get_custom_attachment(custom_url).unlink()
+ self._get_custom_view(custom_url).unlink()
+
+ def save_asset(self, url, bundle_xmlid, content, file_type):
+ """
+ Customize the content of a given asset (scss / js).
+
+ Params:
+ url (src):
+ the URL of the original asset to customize (whether or not the
+ asset was already customized)
+
+ bundle_xmlid (src):
+ the name of the bundle in which the customizations will take
+ effect
+
+ content (src): the new content of the asset (scss / js)
+
+ file_type (src):
+ either 'scss' or 'js' according to the file being customized
+ """
+ custom_url = self.make_custom_asset_file_url(url, bundle_xmlid)
+ datas = base64.b64encode((content or "\n").encode("utf-8"))
+
+ # Check if the file to save had already been modified
+ custom_attachment = self._get_custom_attachment(custom_url)
+ if custom_attachment:
+ # If it was already modified, simply override the corresponding
+ # attachment content
+ custom_attachment.write({"datas": datas})
+ else:
+ # If not, create a new attachment to copy the original scss/js file
+ # content, with its modifications
+ new_attach = {
+ 'name': url.split("/")[-1],
+ 'type': "binary",
+ 'mimetype': (file_type == 'js' and 'text/javascript' or 'text/scss'),
+ 'datas': datas,
+ 'url': custom_url,
+ }
+ new_attach.update(self._save_asset_attachment_hook())
+ self.env["ir.attachment"].create(new_attach)
+
+ # Create a view to extend the template which adds the original file
+ # to link the new modified version instead
+ file_type_info = {
+ 'tag': 'link' if file_type == 'scss' else 'script',
+ 'attribute': 'href' if file_type == 'scss' else 'src',
+ }
+
+ def views_linking_url(view):
+ """
+ Returns whether the view arch has some html tag linked to
+ the url. (note: searching for the URL string is not enough as it
+ could appear in a comment or an xpath expression.)
+ """
+ tree = etree.XML(view.arch)
+ return bool(tree.xpath("//%%(tag)s[@%%(attribute)s='%(url)s']" % {
+ 'url': url,
+ } % file_type_info))
+
+ IrUiView = self.env["ir.ui.view"]
+ view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url)
+ new_view = {
+ 'name': custom_url,
+ 'key': 'web_editor.%s_%s' % (file_type, str(uuid.uuid4())[:6]),
+ 'mode': "extension",
+ 'inherit_id': view_to_xpath.id,
+ 'arch': """
+ <data inherit_id="%(inherit_xml_id)s" name="%(name)s">
+ <xpath expr="//%%(tag)s[@%%(attribute)s='%(url_to_replace)s']" position="attributes">
+ <attribute name="%%(attribute)s">%(new_url)s</attribute>
+ </xpath>
+ </data>
+ """ % {
+ 'inherit_xml_id': view_to_xpath.xml_id,
+ 'name': custom_url,
+ 'url_to_replace': url,
+ 'new_url': custom_url,
+ } % file_type_info
+ }
+ new_view.update(self._save_asset_view_hook())
+ IrUiView.create(new_view)
+
+ self.env["ir.qweb"].clear_caches()
+
+ def _get_custom_attachment(self, custom_url, op='='):
+ """
+ Fetch the ir.attachment record related to the given customized asset.
+
+ Params:
+ custom_url (str): the URL of the customized asset
+ op (str, default: '='): the operator to use to search the records
+
+ Returns:
+ ir.attachment()
+ """
+ assert op in ('in', '='), 'Invalid operator'
+ return self.env["ir.attachment"].search([("url", op, custom_url)])
+
+ def _get_custom_view(self, custom_url, op='='):
+ """
+ Fetch the ir.ui.view record related to the given customized asset (the
+ inheriting view which replace the original asset by the customized one).
+
+ Params:
+ custom_url (str): the URL of the customized asset
+ op (str, default: '='): the operator to use to search the records
+
+ Returns:
+ ir.ui.view()
+ """
+ assert op in ('='), 'Invalid operator'
+ return self.env["ir.ui.view"].search([("name", op, custom_url)])
+
+ def _save_asset_attachment_hook(self):
+ """
+ Returns the additional values to use to write the DB on customized
+ attachment creation.
+
+ Returns:
+ dict
+ """
+ return {}
+
+ def _save_asset_view_hook(self):
+ """
+ Returns the additional values to use to write the DB on customized
+ asset's related view creation.
+
+ Returns:
+ dict
+ """
+ return {}
+
+ def _get_public_asset_xmlids(self):
+ return ["web_editor.compiled_assets_wysiwyg"]
diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py
new file mode 100644
index 00000000..791f9194
--- /dev/null
+++ b/addons/web_editor/models/ir_attachment.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from werkzeug.urls import url_quote
+
+from odoo import api, models, fields, tools
+
+SUPPORTED_IMAGE_MIMETYPES = ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml']
+
+class IrAttachment(models.Model):
+
+ _inherit = "ir.attachment"
+
+ local_url = fields.Char("Attachment URL", compute='_compute_local_url')
+ image_src = fields.Char(compute='_compute_image_src')
+ image_width = fields.Integer(compute='_compute_image_size')
+ image_height = fields.Integer(compute='_compute_image_size')
+ original_id = fields.Many2one('ir.attachment', string="Original (unoptimized, unresized) attachment", index=True)
+
+ def _compute_local_url(self):
+ for attachment in self:
+ if attachment.url:
+ attachment.local_url = attachment.url
+ else:
+ attachment.local_url = '/web/image/%s?unique=%s' % (attachment.id, attachment.checksum)
+
+ @api.depends('mimetype', 'url', 'name')
+ def _compute_image_src(self):
+ for attachment in self:
+ # Only add a src for supported images
+ if attachment.mimetype not in SUPPORTED_IMAGE_MIMETYPES:
+ attachment.image_src = False
+ continue
+
+ if attachment.type == 'url':
+ attachment.image_src = attachment.url
+ else:
+ # Adding unique in URLs for cache-control
+ unique = attachment.checksum[:8]
+ if attachment.url:
+ # For attachments-by-url, unique is used as a cachebuster. They
+ # currently do not leverage max-age headers.
+ attachment.image_src = '%s?unique=%s' % (attachment.url, unique)
+ else:
+ name = url_quote(attachment.name)
+ attachment.image_src = '/web/image/%s-%s/%s' % (attachment.id, unique, name)
+
+ @api.depends('datas')
+ def _compute_image_size(self):
+ for attachment in self:
+ try:
+ image = tools.base64_to_image(attachment.datas)
+ attachment.image_width = image.width
+ attachment.image_height = image.height
+ except Exception:
+ attachment.image_width = 0
+ attachment.image_height = 0
+
+ def _get_media_info(self):
+ """Return a dict with the values that we need on the media dialog."""
+ self.ensure_one()
+ return self._read_format(['id', 'name', 'description', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'])[0]
diff --git a/addons/web_editor/models/ir_http.py b/addons/web_editor/models/ir_http.py
new file mode 100644
index 00000000..d9690f5e
--- /dev/null
+++ b/addons/web_editor/models/ir_http.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+from odoo.http import request
+
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ @classmethod
+ def _dispatch(cls):
+ context = dict(request.context)
+ if 'editable' in request.httprequest.args and 'editable' not in context:
+ context['editable'] = True
+ if 'edit_translations' in request.httprequest.args and 'edit_translations' not in context:
+ context['edit_translations'] = True
+ if context.get('edit_translations') and 'translatable' not in context:
+ context['translatable'] = True
+ request.context = context
+ return super(IrHttp, cls)._dispatch()
+
+ @classmethod
+ def _get_translation_frontend_modules_name(cls):
+ mods = super(IrHttp, cls)._get_translation_frontend_modules_name()
+ return mods + ['web_editor']
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))
diff --git a/addons/web_editor/models/ir_translation.py b/addons/web_editor/models/ir_translation.py
new file mode 100644
index 00000000..0a0e34da
--- /dev/null
+++ b/addons/web_editor/models/ir_translation.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml import etree
+
+from odoo import models, api
+from odoo.tools.translate import encode, xml_translate, html_translate
+
+
+def edit_translation_mapping(data):
+ data = dict(data, model=data['name'].partition(',')[0], value=data['value'] or data['src'])
+ return '<span data-oe-model="%(model)s" data-oe-translation-id="%(id)s" data-oe-translation-state="%(state)s">%(value)s</span>' % data
+
+
+class IrTranslation(models.Model):
+ _inherit = 'ir.translation'
+
+ @api.model
+ def _get_terms_mapping(self, field, records):
+ if self._context.get('edit_translations'):
+ self.insert_missing(field, records)
+ return edit_translation_mapping
+ return super(IrTranslation, self)._get_terms_mapping(field, records)
+
+ def save_html(self, value):
+ """ Convert the HTML fragment ``value`` to XML if necessary, and write
+ it as the value of translation ``self``.
+ """
+ assert len(self) == 1 and self.type == 'model_terms'
+ mname, fname = self.name.split(',')
+ field = self.env[mname]._fields[fname]
+ if field.translate == xml_translate:
+ # wrap value inside a div and parse it as HTML
+ div = "<div>%s</div>" % encode(value)
+ root = etree.fromstring(div, etree.HTMLParser(encoding='utf-8'))
+ # root is html > body > div
+ # serialize div as XML and discard surrounding tags
+ value = etree.tostring(root[0][0], encoding='utf-8')[5:-6]
+ elif field.translate == html_translate:
+ # wrap value inside a div and parse it as HTML
+ div = "<div>%s</div>" % encode(value)
+ root = etree.fromstring(div, etree.HTMLParser(encoding='utf-8'))
+ # root is html > body > div
+ # serialize div as HTML and discard surrounding tags
+ value = etree.tostring(root[0][0], encoding='utf-8', method='html')[5:-6]
+ return self.write({'value': value})
diff --git a/addons/web_editor/models/ir_ui_view.py b/addons/web_editor/models/ir_ui_view.py
new file mode 100644
index 00000000..884e1b5a
--- /dev/null
+++ b/addons/web_editor/models/ir_ui_view.py
@@ -0,0 +1,382 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import copy
+import logging
+import uuid
+from lxml import etree, html
+
+from odoo.exceptions import AccessError
+from odoo import api, models
+
+_logger = logging.getLogger(__name__)
+
+EDITING_ATTRIBUTES = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpath', 'data-note-id']
+
+
+class IrUiView(models.Model):
+ _inherit = 'ir.ui.view'
+
+ def _render(self, values=None, engine='ir.qweb', minimal_qcontext=False):
+ if values and values.get('editable'):
+ try:
+ self.check_access_rights('write')
+ self.check_access_rule('write')
+ except AccessError:
+ values['editable'] = False
+
+ return super(IrUiView, self)._render(values=values, engine=engine, minimal_qcontext=minimal_qcontext)
+
+ @api.model
+ def read_template(self, xml_id):
+ """ This method is deprecated
+ """
+ if xml_id == 'web_editor.colorpicker' and self.env.user.has_group('base.group_user'):
+ # TODO this should be handled another way but was required as a
+ # stable fix in 14.0. The views are now private by default: they
+ # can be read thanks to read_template provided they declare a group
+ # that the user has and that the user has read access rights.
+ #
+ # For the case 'read_template web_editor.colorpicker', it works for
+ # website editor users as the view has the base.group_user group
+ # *and they have access rights thanks to publisher/designer groups*.
+ # For mass mailing users, no such group exists though so they simply
+ # do not have the rights to read that template anymore. Seems safer
+ # to force it for this template only while waiting for a better
+ # access rights refactoring.
+ #
+ # Note: using 'render_public_asset' which allows to bypass rights if
+ # the user has the group the view requires was also a solution.
+ # However, that would turn the 'read' into a 'render', which is
+ # a less stable change.
+ self = self.sudo()
+ return super().read_template(xml_id)
+
+ #------------------------------------------------------
+ # Save from html
+ #------------------------------------------------------
+
+ @api.model
+ def extract_embedded_fields(self, arch):
+ return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
+
+ @api.model
+ def extract_oe_structures(self, arch):
+ return arch.xpath('//*[hasclass("oe_structure")][contains(@id, "oe_structure")]')
+
+ @api.model
+ def get_default_lang_code(self):
+ return False
+
+ @api.model
+ def save_embedded_field(self, el):
+ Model = self.env[el.get('data-oe-model')]
+ field = el.get('data-oe-field')
+
+ model = 'ir.qweb.field.' + el.get('data-oe-type')
+ converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
+ value = converter.from_html(Model, Model._fields[field], el)
+
+ if value is not None:
+ # TODO: batch writes?
+ if not self.env.context.get('lang') and self.get_default_lang_code():
+ Model.browse(int(el.get('data-oe-id'))).with_context(lang=self.get_default_lang_code()).write({field: value})
+ else:
+ Model.browse(int(el.get('data-oe-id'))).write({field: value})
+
+ def save_oe_structure(self, el):
+ self.ensure_one()
+
+ if el.get('id') in self.key:
+ # Do not inherit if the oe_structure already has its own inheriting view
+ return False
+
+ arch = etree.Element('data')
+ xpath = etree.Element('xpath', expr="//*[hasclass('oe_structure')][@id='{}']".format(el.get('id')), position="replace")
+ arch.append(xpath)
+ attributes = {k: v for k, v in el.attrib.items() if k not in EDITING_ATTRIBUTES}
+ structure = etree.Element(el.tag, attrib=attributes)
+ structure.text = el.text
+ xpath.append(structure)
+ for child in el.iterchildren(tag=etree.Element):
+ structure.append(copy.deepcopy(child))
+
+ vals = {
+ 'inherit_id': self.id,
+ 'name': '%s (%s)' % (self.name, el.get('id')),
+ 'arch': self._pretty_arch(arch),
+ 'key': '%s_%s' % (self.key, el.get('id')),
+ 'type': 'qweb',
+ 'mode': 'extension',
+ }
+ vals.update(self._save_oe_structure_hook())
+ self.env['ir.ui.view'].create(vals)
+
+ return True
+
+ @api.model
+ def _save_oe_structure_hook(self):
+ return {}
+
+ @api.model
+ def _pretty_arch(self, arch):
+ # remove_blank_string does not seem to work on HTMLParser, and
+ # pretty-printing with lxml more or less requires stripping
+ # whitespace: http://lxml.de/FAQ.html#why-doesn-t-the-pretty-print-option-reformat-my-xml-output
+ # so serialize to XML, parse as XML (remove whitespace) then serialize
+ # as XML (pretty print)
+ arch_no_whitespace = etree.fromstring(
+ etree.tostring(arch, encoding='utf-8'),
+ parser=etree.XMLParser(encoding='utf-8', remove_blank_text=True))
+ return etree.tostring(
+ arch_no_whitespace, encoding='unicode', pretty_print=True)
+
+ @api.model
+ def _are_archs_equal(self, arch1, arch2):
+ # Note that comparing the strings would not be ok as attributes order
+ # must not be relevant
+ if arch1.tag != arch2.tag:
+ return False
+ if arch1.text != arch2.text:
+ return False
+ if arch1.tail != arch2.tail:
+ return False
+ if arch1.attrib != arch2.attrib:
+ return False
+ if len(arch1) != len(arch2):
+ return False
+ return all(self._are_archs_equal(arch1, arch2) for arch1, arch2 in zip(arch1, arch2))
+
+ def replace_arch_section(self, section_xpath, replacement, replace_tail=False):
+ # the root of the arch section shouldn't actually be replaced as it's
+ # not really editable itself, only the content truly is editable.
+ self.ensure_one()
+ arch = etree.fromstring(self.arch.encode('utf-8'))
+ # => get the replacement root
+ if not section_xpath:
+ root = arch
+ else:
+ # ensure there's only one match
+ [root] = arch.xpath(section_xpath)
+
+ root.text = replacement.text
+
+ # We need to replace some attrib for styles changes on the root element
+ for attribute in ('style', 'class'):
+ if attribute in replacement.attrib:
+ root.attrib[attribute] = replacement.attrib[attribute]
+
+ # Note: after a standard edition, the tail *must not* be replaced
+ if replace_tail:
+ root.tail = replacement.tail
+ # replace all children
+ del root[:]
+ for child in replacement:
+ root.append(copy.deepcopy(child))
+
+ return arch
+
+ @api.model
+ def to_field_ref(self, el):
+ # filter out meta-information inserted in the document
+ attributes = {k: v for k, v in el.attrib.items()
+ if not k.startswith('data-oe-')}
+ attributes['t-field'] = el.get('data-oe-expression')
+
+ out = html.html_parser.makeelement(el.tag, attrib=attributes)
+ out.tail = el.tail
+ return out
+
+ @api.model
+ def to_empty_oe_structure(self, el):
+ out = html.html_parser.makeelement(el.tag, attrib=el.attrib)
+ out.tail = el.tail
+ return out
+
+ @api.model
+ def _set_noupdate(self):
+ self.sudo().mapped('model_data_id').write({'noupdate': True})
+
+ def save(self, value, xpath=None):
+ """ Update a view section. The view section may embed fields to write
+
+ Note that `self` record might not exist when saving an embed field
+
+ :param str xpath: valid xpath to the tag to replace
+ """
+ self.ensure_one()
+
+ arch_section = html.fromstring(
+ value, parser=html.HTMLParser(encoding='utf-8'))
+
+ if xpath is None:
+ # value is an embedded field on its own, not a view section
+ self.save_embedded_field(arch_section)
+ return
+
+ for el in self.extract_embedded_fields(arch_section):
+ self.save_embedded_field(el)
+
+ # transform embedded field back to t-field
+ el.getparent().replace(el, self.to_field_ref(el))
+
+ for el in self.extract_oe_structures(arch_section):
+ if self.save_oe_structure(el):
+ # empty oe_structure in parent view
+ empty = self.to_empty_oe_structure(el)
+ if el == arch_section:
+ arch_section = empty
+ else:
+ el.getparent().replace(el, empty)
+
+ new_arch = self.replace_arch_section(xpath, arch_section)
+ old_arch = etree.fromstring(self.arch.encode('utf-8'))
+ if not self._are_archs_equal(old_arch, new_arch):
+ self._set_noupdate()
+ self.write({'arch': self._pretty_arch(new_arch)})
+
+ @api.model
+ def _view_get_inherited_children(self, view):
+ return view.inherit_children_ids
+
+ @api.model
+ def _view_obj(self, view_id):
+ if isinstance(view_id, str):
+ return self.search([('key', '=', view_id)], limit=1) or self.env.ref(view_id)
+ elif isinstance(view_id, int):
+ return self.browse(view_id)
+ # It can already be a view object when called by '_views_get()' that is calling '_view_obj'
+ # for it's inherit_children_ids, passing them directly as object record.
+ return view_id
+
+ # Returns all views (called and inherited) related to a view
+ # Used by translation mechanism, SEO and optional templates
+
+ @api.model
+ def _views_get(self, view_id, get_children=True, bundles=False, root=True, visited=None):
+ """ For a given view ``view_id``, should return:
+ * the view itself
+ * all views inheriting from it, enabled or not
+ - but not the optional children of a non-enabled child
+ * all views called from it (via t-call)
+ :returns recordset of ir.ui.view
+ """
+ try:
+ view = self._view_obj(view_id)
+ except ValueError:
+ _logger.warning("Could not find view object with view_id '%s'", view_id)
+ return self.env['ir.ui.view']
+
+ if visited is None:
+ visited = []
+ while root and view.inherit_id:
+ view = view.inherit_id
+
+ views_to_return = view
+
+ node = etree.fromstring(view.arch)
+ xpath = "//t[@t-call]"
+ if bundles:
+ xpath += "| //t[@t-call-assets]"
+ for child in node.xpath(xpath):
+ try:
+ called_view = self._view_obj(child.get('t-call', child.get('t-call-assets')))
+ except ValueError:
+ continue
+ if called_view and called_view not in views_to_return and called_view.id not in visited:
+ views_to_return += self._views_get(called_view, get_children=get_children, bundles=bundles, visited=visited + views_to_return.ids)
+
+ if not get_children:
+ return views_to_return
+
+ extensions = self._view_get_inherited_children(view)
+
+ # Keep children in a deterministic order regardless of their applicability
+ for extension in extensions.sorted(key=lambda v: v.id):
+ # only return optional grandchildren if this child is enabled
+ if extension.id not in visited:
+ for ext_view in self._views_get(extension, get_children=extension.active, root=False, visited=visited + views_to_return.ids):
+ if ext_view not in views_to_return:
+ views_to_return += ext_view
+ return views_to_return
+
+ @api.model
+ def get_related_views(self, key, bundles=False):
+ """ Get inherit view's informations of the template ``key``.
+ returns templates info (which can be active or not)
+ ``bundles=True`` returns also the asset bundles
+ """
+ user_groups = set(self.env.user.groups_id)
+ View = self.with_context(active_test=False, lang=None)
+ views = View._views_get(key, bundles=bundles)
+ return views.filtered(lambda v: not v.groups_id or len(user_groups.intersection(v.groups_id)))
+
+ # --------------------------------------------------------------------------
+ # Snippet saving
+ # --------------------------------------------------------------------------
+
+ @api.model
+ def _get_snippet_addition_view_key(self, template_key, key):
+ return '%s.%s' % (template_key, key)
+
+ @api.model
+ def _snippet_save_view_values_hook(self):
+ return {}
+
+ @api.model
+ def save_snippet(self, name, arch, template_key, snippet_key, thumbnail_url):
+ """
+ Saves a new snippet arch so that it appears with the given name when
+ using the given snippets template.
+
+ :param name: the name of the snippet to save
+ :param arch: the html structure of the snippet to save
+ :param template_key: the key of the view regrouping all snippets in
+ which the snippet to save is meant to appear
+ :param snippet_key: the key (without module part) to identify
+ the snippet from which the snippet to save originates
+ :param thumbnail_url: the url of the thumbnail to use when displaying
+ the snippet to save
+ """
+ app_name = template_key.split('.')[0]
+ snippet_key = '%s_%s' % (snippet_key, uuid.uuid4().hex)
+ full_snippet_key = '%s.%s' % (app_name, snippet_key)
+
+ # html to xml to add '/' at the end of self closing tags like br, ...
+ xml_arch = etree.tostring(html.fromstring(arch), encoding='utf-8')
+ new_snippet_view_values = {
+ 'name': name,
+ 'key': full_snippet_key,
+ 'type': 'qweb',
+ 'arch': xml_arch,
+ }
+ new_snippet_view_values.update(self._snippet_save_view_values_hook())
+ self.create(new_snippet_view_values)
+
+ custom_section = self.search([('key', '=', template_key)])
+ snippet_addition_view_values = {
+ 'name': name + ' Block',
+ 'key': self._get_snippet_addition_view_key(template_key, snippet_key),
+ 'inherit_id': custom_section.id,
+ 'type': 'qweb',
+ 'arch': """
+ <data inherit_id="%s">
+ <xpath expr="//div[@id='snippet_custom']" position="attributes">
+ <attribute name="class" remove="d-none" separator=" "/>
+ </xpath>
+ <xpath expr="//div[@id='snippet_custom_body']" position="inside">
+ <t t-snippet="%s" t-thumbnail="%s"/>
+ </xpath>
+ </data>
+ """ % (template_key, full_snippet_key, thumbnail_url),
+ }
+ snippet_addition_view_values.update(self._snippet_save_view_values_hook())
+ self.create(snippet_addition_view_values)
+
+ @api.model
+ def delete_snippet(self, view_id, template_key):
+ snippet_view = self.browse(view_id)
+ key = snippet_view.key.split('.')[1]
+ custom_key = self._get_snippet_addition_view_key(template_key, key)
+ snippet_addition_view = self.search([('key', '=', custom_key)])
+ (snippet_addition_view | snippet_view).unlink()
diff --git a/addons/web_editor/models/test_models.py b/addons/web_editor/models/test_models.py
new file mode 100644
index 00000000..282b703c
--- /dev/null
+++ b/addons/web_editor/models/test_models.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields
+
+
+class ConverterTest(models.Model):
+ _name = 'web_editor.converter.test'
+ _description = 'Web Editor Converter Test'
+
+ # disable translation export for those brilliant field labels and values
+ _translate = False
+
+ char = fields.Char()
+ integer = fields.Integer()
+ float = fields.Float()
+ numeric = fields.Float(digits=(16, 2))
+ many2one = fields.Many2one('web_editor.converter.test.sub')
+ binary = fields.Binary(attachment=False)
+ date = fields.Date()
+ datetime = fields.Datetime()
+ selection_str = fields.Selection([
+ ('A', "Qu'il n'est pas arrivé à Toronto"),
+ ('B', "Qu'il était supposé arriver à Toronto"),
+ ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"),
+ ('D', "La réponse D"),
+ ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et "
+ u"qu'il fait une escale technique à St Claude, on dit:")
+ html = fields.Html()
+ text = fields.Text()
+
+
+class ConverterTestSub(models.Model):
+ _name = 'web_editor.converter.test.sub'
+ _description = 'Web Editor Converter Subtest'
+
+ name = fields.Char()