ir_ui_view.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import copy
  4. import logging
  5. import uuid
  6. from lxml import etree, html
  7. from odoo import api, models, _
  8. from odoo.osv import expression
  9. from odoo.exceptions import ValidationError
  10. _logger = logging.getLogger(__name__)
  11. EDITING_ATTRIBUTES = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpath', 'data-note-id']
  12. class IrUiView(models.Model):
  13. _inherit = 'ir.ui.view'
  14. #------------------------------------------------------
  15. # Save from html
  16. #------------------------------------------------------
  17. @api.model
  18. def extract_embedded_fields(self, arch):
  19. return arch.xpath('//*[@data-oe-model != "ir.ui.view"]')
  20. @api.model
  21. def extract_oe_structures(self, arch):
  22. return arch.xpath('//*[hasclass("oe_structure")][contains(@id, "oe_structure")]')
  23. @api.model
  24. def get_default_lang_code(self):
  25. return False
  26. @api.model
  27. def save_embedded_field(self, el):
  28. Model = self.env[el.get('data-oe-model')]
  29. field = el.get('data-oe-field')
  30. model = 'ir.qweb.field.' + el.get('data-oe-type')
  31. converter = self.env[model] if model in self.env else self.env['ir.qweb.field']
  32. try:
  33. value = converter.from_html(Model, Model._fields[field], el)
  34. except ValueError:
  35. raise ValidationError(_("Invalid field value for %s: %s", Model._fields[field].string, el.text_content().strip()))
  36. if value is not None:
  37. # TODO: batch writes?
  38. if not self.env.context.get('lang') and self.get_default_lang_code():
  39. Model.browse(int(el.get('data-oe-id'))).with_context(lang=self.get_default_lang_code()).write({field: value})
  40. else:
  41. Model.browse(int(el.get('data-oe-id'))).write({field: value})
  42. def save_oe_structure(self, el):
  43. self.ensure_one()
  44. if el.get('id') in self.key:
  45. # Do not inherit if the oe_structure already has its own inheriting view
  46. return False
  47. arch = etree.Element('data')
  48. xpath = etree.Element('xpath', expr="//*[hasclass('oe_structure')][@id='{}']".format(el.get('id')), position="replace")
  49. arch.append(xpath)
  50. attributes = {k: v for k, v in el.attrib.items() if k not in EDITING_ATTRIBUTES}
  51. structure = etree.Element(el.tag, attrib=attributes)
  52. structure.text = el.text
  53. xpath.append(structure)
  54. for child in el.iterchildren(tag=etree.Element):
  55. structure.append(copy.deepcopy(child))
  56. vals = {
  57. 'inherit_id': self.id,
  58. 'name': '%s (%s)' % (self.name, el.get('id')),
  59. 'arch': self._pretty_arch(arch),
  60. 'key': '%s_%s' % (self.key, el.get('id')),
  61. 'type': 'qweb',
  62. 'mode': 'extension',
  63. }
  64. vals.update(self._save_oe_structure_hook())
  65. self.env['ir.ui.view'].create(vals)
  66. return True
  67. @api.model
  68. def _save_oe_structure_hook(self):
  69. return {}
  70. @api.model
  71. def _pretty_arch(self, arch):
  72. # TODO: Remove this method in 16.3.
  73. return etree.tostring(arch, encoding='unicode')
  74. @api.model
  75. def _are_archs_equal(self, arch1, arch2):
  76. # Note that comparing the strings would not be ok as attributes order
  77. # must not be relevant
  78. if arch1.tag != arch2.tag:
  79. return False
  80. if arch1.text != arch2.text:
  81. return False
  82. if arch1.tail != arch2.tail:
  83. return False
  84. if arch1.attrib != arch2.attrib:
  85. return False
  86. if len(arch1) != len(arch2):
  87. return False
  88. return all(self._are_archs_equal(arch1, arch2) for arch1, arch2 in zip(arch1, arch2))
  89. @api.model
  90. def _get_allowed_root_attrs(self):
  91. return ['style', 'class']
  92. def replace_arch_section(self, section_xpath, replacement, replace_tail=False):
  93. # the root of the arch section shouldn't actually be replaced as it's
  94. # not really editable itself, only the content truly is editable.
  95. self.ensure_one()
  96. arch = etree.fromstring(self.arch.encode('utf-8'))
  97. # => get the replacement root
  98. if not section_xpath:
  99. root = arch
  100. else:
  101. # ensure there's only one match
  102. [root] = arch.xpath(section_xpath)
  103. root.text = replacement.text
  104. # We need to replace some attrib for styles changes on the root element
  105. for attribute in self._get_allowed_root_attrs():
  106. if attribute in replacement.attrib:
  107. root.attrib[attribute] = replacement.attrib[attribute]
  108. # Note: after a standard edition, the tail *must not* be replaced
  109. if replace_tail:
  110. root.tail = replacement.tail
  111. # replace all children
  112. del root[:]
  113. for child in replacement:
  114. root.append(copy.deepcopy(child))
  115. return arch
  116. @api.model
  117. def to_field_ref(self, el):
  118. # filter out meta-information inserted in the document
  119. attributes = {k: v for k, v in el.attrib.items()
  120. if not k.startswith('data-oe-')}
  121. attributes['t-field'] = el.get('data-oe-expression')
  122. out = html.html_parser.makeelement(el.tag, attrib=attributes)
  123. out.tail = el.tail
  124. return out
  125. @api.model
  126. def to_empty_oe_structure(self, el):
  127. out = html.html_parser.makeelement(el.tag, attrib=el.attrib)
  128. out.tail = el.tail
  129. return out
  130. @api.model
  131. def _set_noupdate(self):
  132. self.sudo().mapped('model_data_id').write({'noupdate': True})
  133. def save(self, value, xpath=None):
  134. """ Update a view section. The view section may embed fields to write
  135. Note that `self` record might not exist when saving an embed field
  136. :param str xpath: valid xpath to the tag to replace
  137. """
  138. self.ensure_one()
  139. arch_section = html.fromstring(
  140. value, parser=html.HTMLParser(encoding='utf-8'))
  141. if xpath is None:
  142. # value is an embedded field on its own, not a view section
  143. self.save_embedded_field(arch_section)
  144. return
  145. for el in self.extract_embedded_fields(arch_section):
  146. self.save_embedded_field(el)
  147. # transform embedded field back to t-field
  148. el.getparent().replace(el, self.to_field_ref(el))
  149. for el in self.extract_oe_structures(arch_section):
  150. if self.save_oe_structure(el):
  151. # empty oe_structure in parent view
  152. empty = self.to_empty_oe_structure(el)
  153. if el == arch_section:
  154. arch_section = empty
  155. else:
  156. el.getparent().replace(el, empty)
  157. new_arch = self.replace_arch_section(xpath, arch_section)
  158. old_arch = etree.fromstring(self.arch.encode('utf-8'))
  159. if not self._are_archs_equal(old_arch, new_arch):
  160. self._set_noupdate()
  161. self.write({'arch': self._pretty_arch(new_arch)})
  162. @api.model
  163. def _view_get_inherited_children(self, view):
  164. if self._context.get('no_primary_children', False):
  165. original_hierarchy = self._context.get('__views_get_original_hierarchy', [])
  166. return view.inherit_children_ids.filtered(lambda extension: extension.mode != 'primary' or extension.id in original_hierarchy)
  167. return view.inherit_children_ids
  168. @api.model
  169. def _view_obj(self, view_id):
  170. if isinstance(view_id, str):
  171. return self.search([('key', '=', view_id)], limit=1) or self.env.ref(view_id)
  172. elif isinstance(view_id, int):
  173. return self.browse(view_id)
  174. # It can already be a view object when called by '_views_get()' that is calling '_view_obj'
  175. # for it's inherit_children_ids, passing them directly as object record.
  176. return view_id
  177. # Returns all views (called and inherited) related to a view
  178. # Used by translation mechanism, SEO and optional templates
  179. @api.model
  180. def _views_get(self, view_id, get_children=True, bundles=False, root=True, visited=None):
  181. """ For a given view ``view_id``, should return:
  182. * the view itself (starting from its top most parent)
  183. * all views inheriting from it, enabled or not
  184. - but not the optional children of a non-enabled child
  185. * all views called from it (via t-call)
  186. :returns recordset of ir.ui.view
  187. """
  188. try:
  189. view = self._view_obj(view_id)
  190. except ValueError:
  191. _logger.warning("Could not find view object with view_id '%s'", view_id)
  192. return self.env['ir.ui.view']
  193. if visited is None:
  194. visited = []
  195. original_hierarchy = self._context.get('__views_get_original_hierarchy', [])
  196. while root and view.inherit_id:
  197. original_hierarchy.append(view.id)
  198. view = view.inherit_id
  199. views_to_return = view
  200. node = etree.fromstring(view.arch)
  201. xpath = "//t[@t-call]"
  202. if bundles:
  203. xpath += "| //t[@t-call-assets]"
  204. for child in node.xpath(xpath):
  205. try:
  206. called_view = self._view_obj(child.get('t-call', child.get('t-call-assets')))
  207. except ValueError:
  208. continue
  209. if called_view and called_view not in views_to_return and called_view.id not in visited:
  210. views_to_return += self._views_get(called_view, get_children=get_children, bundles=bundles, visited=visited + views_to_return.ids)
  211. if not get_children:
  212. return views_to_return
  213. extensions = self._view_get_inherited_children(view)
  214. # Keep children in a deterministic order regardless of their applicability
  215. for extension in extensions.sorted(key=lambda v: v.id):
  216. # only return optional grandchildren if this child is enabled
  217. if extension.id not in visited:
  218. for ext_view in self._views_get(extension, get_children=extension.active, root=False, visited=visited + views_to_return.ids):
  219. if ext_view not in views_to_return:
  220. views_to_return += ext_view
  221. return views_to_return
  222. @api.model
  223. def get_related_views(self, key, bundles=False):
  224. """ Get inherit view's informations of the template ``key``.
  225. returns templates info (which can be active or not)
  226. ``bundles=True`` returns also the asset bundles
  227. """
  228. user_groups = set(self.env.user.groups_id)
  229. View = self.with_context(active_test=False, lang=None)
  230. views = View._views_get(key, bundles=bundles)
  231. return views.filtered(lambda v: not v.groups_id or len(user_groups.intersection(v.groups_id)))
  232. # --------------------------------------------------------------------------
  233. # Snippet saving
  234. # --------------------------------------------------------------------------
  235. @api.model
  236. def _get_snippet_addition_view_key(self, template_key, key):
  237. return '%s.%s' % (template_key, key)
  238. @api.model
  239. def _snippet_save_view_values_hook(self):
  240. return {}
  241. def _find_available_name(self, name, used_names):
  242. attempt = 1
  243. candidate_name = name
  244. while candidate_name in used_names:
  245. attempt += 1
  246. candidate_name = f"{name} ({attempt})"
  247. return candidate_name
  248. @api.model
  249. def save_snippet(self, name, arch, template_key, snippet_key, thumbnail_url):
  250. """
  251. Saves a new snippet arch so that it appears with the given name when
  252. using the given snippets template.
  253. :param name: the name of the snippet to save
  254. :param arch: the html structure of the snippet to save
  255. :param template_key: the key of the view regrouping all snippets in
  256. which the snippet to save is meant to appear
  257. :param snippet_key: the key (without module part) to identify
  258. the snippet from which the snippet to save originates
  259. :param thumbnail_url: the url of the thumbnail to use when displaying
  260. the snippet to save
  261. """
  262. app_name = template_key.split('.')[0]
  263. snippet_key = '%s_%s' % (snippet_key, uuid.uuid4().hex)
  264. full_snippet_key = '%s.%s' % (app_name, snippet_key)
  265. # find available name
  266. current_website = self.env['website'].browse(self._context.get('website_id'))
  267. website_domain = current_website.website_domain()
  268. used_names = self.search(expression.AND([
  269. [('name', '=like', '%s%%' % name)], website_domain
  270. ])).mapped('name')
  271. name = self._find_available_name(name, used_names)
  272. # html to xml to add '/' at the end of self closing tags like br, ...
  273. xml_arch = etree.tostring(html.fromstring(arch), encoding='utf-8')
  274. new_snippet_view_values = {
  275. 'name': name,
  276. 'key': full_snippet_key,
  277. 'type': 'qweb',
  278. 'arch': xml_arch,
  279. }
  280. new_snippet_view_values.update(self._snippet_save_view_values_hook())
  281. self.create(new_snippet_view_values)
  282. custom_section = self.search([('key', '=', template_key)])
  283. snippet_addition_view_values = {
  284. 'name': name + ' Block',
  285. 'key': self._get_snippet_addition_view_key(template_key, snippet_key),
  286. 'inherit_id': custom_section.id,
  287. 'type': 'qweb',
  288. 'arch': """
  289. <data inherit_id="%s">
  290. <xpath expr="//div[@id='snippet_custom']" position="attributes">
  291. <attribute name="class" remove="d-none" separator=" "/>
  292. </xpath>
  293. <xpath expr="//div[@id='snippet_custom_body']" position="inside">
  294. <t t-snippet="%s" t-thumbnail="%s"/>
  295. </xpath>
  296. </data>
  297. """ % (template_key, full_snippet_key, thumbnail_url),
  298. }
  299. snippet_addition_view_values.update(self._snippet_save_view_values_hook())
  300. self.create(snippet_addition_view_values)
  301. @api.model
  302. def rename_snippet(self, name, view_id, template_key):
  303. snippet_view = self.browse(view_id)
  304. key = snippet_view.key.split('.')[1]
  305. custom_key = self._get_snippet_addition_view_key(template_key, key)
  306. snippet_addition_view = self.search([('key', '=', custom_key)])
  307. if snippet_addition_view:
  308. snippet_addition_view.name = name + ' Block'
  309. snippet_view.name = name
  310. @api.model
  311. def delete_snippet(self, view_id, template_key):
  312. snippet_view = self.browse(view_id)
  313. key = snippet_view.key.split('.')[1]
  314. custom_key = self._get_snippet_addition_view_key(template_key, key)
  315. snippet_addition_view = self.search([('key', '=', custom_key)])
  316. (snippet_addition_view | snippet_view).unlink()