# -*- 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': 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 {} 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': """ """ % (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()