The following basic case has been broken in website forever since [1] in Odoo 8:
```html
<p><span>a</span><span>b</span></p>
```
Saving the above html results in:
```html
<p>
<span>a</span>
<span>b</span>
</p>
```
Which, when re-rendered back in the DOM renders equivalent to:
```html
<p><span>a</span> <span>b</span></p>
```
Note the space between "a" and "b". That is because etree will pretty print
nodes with indentations as long as they do not have text content, and that
indentation is collapsed into a single visible space by the browser when
inserted in the DOM. This is not limited to span nodes as the same applies to
any inline node. This is very easily reproduced in website on any version:
- Drop a Text snippet.
- Replace all the content of the snippet by "ab".
- Put "a" in bold and "b" in italic.
- Save.
- Notice that the saved version is now "a b" instead "ab".
The user has no way of removing this space easily because even if they manage to
do it by any mean, the server will pretty print the html again and the space
will reappear. The only way to circumvent this is to have some text content as
sibling of the inline nodes.
Consider this:
```
>>> etree.tostring(html.fromstring('<p><span>a</span><span>b</span></p>'), pretty_print=True)
b'<p>\n <span>a</span>\n <span>b</span>\n</p>\n'
```
Which is incorrect, while this:
```
>>> etree.tostring(html.fromstring('<p><span>a</span><span>b</span>c</p>'), pretty_print=True)
b'<p><span>a</span><span>b</span>c</p>\n'
```
Is correct.
We could fix it using a heavy hack that would leverage this behavior by
inserting one of the few unicode control characters that etree considers to be
actual content, and therefore preventing pretty printing for this node. The
server would then remove the control character to avoid polluting the actual
views. This would have the side-effect of forbidding this control character to
ever be used in a view however, and would obviously be an extremely ugly hack.
The alternative which was chosen in accordance with Antony (al) and Xavier (xmo)
is to disable pretty printing altogether, since the original commit [1] seem to
have introduced it as a fix for an old version of the editor rather than for the
intrinsic qualities of having pretty printed views.
If we ever want to re-enable pretty printing in the future, I suggest it be
implemented in JS because the browser is the only one able to assert whether a
node is going to be treated as a block or as an inline with respect to the
current CSS rules in application.
task-3142796
opw-3122373
opw-3186250
[1]: https://github.com/odoo/odoo/commit/6b857b6eeb59137a71385f98c82c440ac82cd45d
closes odoo/odoo#115159
X-original-commit: 3c6b9249482454a931f384ffc66ceec64dffbcfc
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
375 lines
15 KiB
Python
375 lines
15 KiB
Python
# -*- 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 import api, models, _
|
|
from odoo.osv import expression
|
|
from odoo.exceptions import ValidationError
|
|
|
|
_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'
|
|
|
|
#------------------------------------------------------
|
|
# 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']
|
|
|
|
try:
|
|
value = converter.from_html(Model, Model._fields[field], el)
|
|
except ValueError:
|
|
raise ValidationError(_("Invalid field value for %s: %s", Model._fields[field].string, el.text_content().strip()))
|
|
|
|
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': etree.tostring(arch, encoding='unicode'),
|
|
'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 _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))
|
|
|
|
@api.model
|
|
def _get_allowed_root_attrs(self):
|
|
return ['style', 'class']
|
|
|
|
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 self._get_allowed_root_attrs():
|
|
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': etree.tostring(new_arch, encoding='unicode')})
|
|
|
|
@api.model
|
|
def _view_get_inherited_children(self, view):
|
|
if self._context.get('no_primary_children', False):
|
|
original_hierarchy = self._context.get('__views_get_original_hierarchy', [])
|
|
return view.inherit_children_ids.filtered(lambda extension: extension.mode != 'primary' or extension.id in original_hierarchy)
|
|
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 (starting from its top most parent)
|
|
* 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 = []
|
|
original_hierarchy = self._context.get('__views_get_original_hierarchy', [])
|
|
while root and view.inherit_id:
|
|
original_hierarchy.append(view.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 {}
|
|
|
|
def _find_available_name(self, name, used_names):
|
|
attempt = 1
|
|
candidate_name = name
|
|
while candidate_name in used_names:
|
|
attempt += 1
|
|
candidate_name = f"{name} ({attempt})"
|
|
return candidate_name
|
|
|
|
@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)
|
|
|
|
# find available name
|
|
current_website = self.env['website'].browse(self._context.get('website_id'))
|
|
website_domain = current_website.website_domain()
|
|
used_names = self.search(expression.AND([
|
|
[('name', '=like', '%s%%' % name)], website_domain
|
|
])).mapped('name')
|
|
name = self._find_available_name(name, used_names)
|
|
|
|
# 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 rename_snippet(self, name, 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)])
|
|
if snippet_addition_view:
|
|
snippet_addition_view.name = name + ' Block'
|
|
snippet_view.name = name
|
|
|
|
@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()
|