diff options
Diffstat (limited to 'addons/web_editor/models')
| -rw-r--r-- | addons/web_editor/models/__init__.py | 12 | ||||
| -rw-r--r-- | addons/web_editor/models/assets.py | 278 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_attachment.py | 62 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_http.py | 26 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_qweb.py | 618 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_translation.py | 46 | ||||
| -rw-r--r-- | addons/web_editor/models/ir_ui_view.py | 382 | ||||
| -rw-r--r-- | addons/web_editor/models/test_models.py | 37 |
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() |
