# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from lxml import etree from lxml.builder import E import functools import json import uuid import random from odoo import api, models, _ from odoo.exceptions import UserError, ValidationError from ..controllers.keyed_xml_differ import KeyedXmlDiffer from odoo.tools.template_inheritance import apply_inheritance_specs from odoo.addons.web_studio.controllers.report import get_report_view_copy CONTAINER_TYPES = ( 'group', 'page', 'sheet', 'div', 'ul', 'li', 'notebook', ) class Base(models.AbstractModel): _inherit = 'base' @api.model def _get_view_cache_key(self, *args, **kwargs): if self.env.context.get('studio'): self = self.with_context(no_address_format=True) key = super()._get_view_cache_key(*args, **kwargs) return key + (self.env.context.get("studio"),) @api.model def _get_view_field_attributes(self): keys = super()._get_view_field_attributes() if self.env.context.get("studio"): keys.append('manual') return keys @api.model def get_views(self, views, options=None): """Add an option to enable features for studio. :param dict options: a dict optional boolean flags, set to enable: ``studio`` enable studio feature, will add 'studio' and 'no_address_format' in the context. """ if options and options.get('studio'): self = self.with_context(studio=True, no_address_format=True) return super().get_views(views, options) class IrUiView(models.Model): _name = 'ir.ui.view' _description = 'View' _inherit = ['studio.mixin', 'ir.ui.view'] TEMPLATE_VIEWS_BLACKLIST = [ 'web.html_container', 'web.report_layout', 'web.external_layout', 'web.internal_layout', 'web.basic_layout', 'web.minimal_layout', 'web.external_layout_striped', 'web.external_layout_boxed', 'web.external_layout_bold', 'web.external_layout_standard', 'web.external_layout_folder', 'web.external_layout_wave', 'web.external_layout_bubble', ] def _get_closest_primary_view(self): self.ensure_one() view = self while view.mode != "primary": view = view.inherit_id return view def _get_x2many_missing_view_archs(self, field, field_node, node_info): missing = super()._get_x2many_missing_view_archs(field, field_node, node_info) if not missing or not self.env.context.get("studio"): return missing for arch, _ in missing: arch.set("studio_subview_inlined", "1") return missing def _postprocess_debug_to_cache(self, tree): if self.env.context.get('studio'): return tree return super()._postprocess_debug_to_cache(tree) def _postprocess_debug(self, tree): if self.env.context.get('studio'): for node in tree.xpath('//*[@__debug__]'): node.attrib.pop('__debug__') return tree else: return super()._postprocess_debug(tree) def _postprocess_access_rights(self, tree): # apply_group only returns the view groups ids. # As we need also need their name and display in Studio to edit these groups # (many2many widget), they have been added to node (only in Studio). Also, # we need ids of the fields inside map view(that displays marker popup) to edit # them with similar many2many widget. So we also add them to node (only in Studio). # This preprocess cannot be done at validation time because the # attributes `studio_groups`, `studio_map_field_ids` and `studio_pivot_measure_fields` are not RNG valid. # In the context of Studio, we process the access rights for groups here, # since instead of deleting nodes that the user doesn't have access, we set them invisible, # to prevent incorrect xpath's being generated by the frontend to then apply the view customizations to. if self.env.context.get('studio'): group_definitions = self.env['res.groups']._get_group_definitions() node_groups = {} set_invisible_nodes = dict() for node in tree.xpath('//*[@groups]'): if node.get('groups'): node_groups[node] = node.get('groups') # check the read/visibility access @functools.cache def has_access(groups_key): groups = group_definitions.from_key(groups_key) return groups.matches(user_group_ids) user_group_ids = self.env.user._get_group_ids() for node in tree.xpath('//*[@__groups_key__]'): if not has_access(node.attrib.pop('__groups_key__')): # Make invisible nodes for which the user is not part of the group, # and remove the `groups` from the node before calling super so the nodes are not deleted. set_invisible_nodes[node] = node.get("invisible") or node.get("column_invisible") or "False" if node.tag == 'field' and 'model_groups' in node.attrib and not has_access(node.attrib.pop('model_groups')): # fields with `groups` in the Python model cannot be read at all by users not part of the group. # For them, set a special argument to tell the web client to not read the content of the field # while still displaying the field in the view. # The form view of `res.users` has fake fields, not existing in the model, # so use `_fields.get(...)` instead of `_fields[...]` node.set('studio_no_fetch', '1') def is_in_list(node): parent = node.getparent() return parent is not None and parent.tag == "list" for node, invisible_value in set_invisible_nodes.items(): if is_in_list(node): column_invisible = 'True' node.set('column_invisible', column_invisible) else: invisible = 'True' node.set('invisible', invisible) node.set('actual_invisible', invisible_value) model = tree.get('model_access_rights') res = super()._postprocess_access_rights(tree) for node, groups in node_groups.items(): # Set back the `groups` on the nodes after the call to super. node.set('groups', groups) self.set_studio_groups(node) if tree.tag == 'map': self.set_studio_map_popup_fields(model, tree) if tree.tag == 'pivot': self.set_studio_pivot_measure_fields(model, tree) return res return super()._postprocess_access_rights(tree) def _postprocess_attributes(self, node, name_manager, node_info): if self.env.context.get('studio'): groups = node.get('groups', '') super()._postprocess_attributes(node, name_manager, node_info) if groups: node.attrib['groups'] = groups if node.tag == 'field' and node.get('__groups_key__'): node.set('model_groups', node_info['model_groups'].key) else: super()._postprocess_attributes(node, name_manager, node_info) @api.model def set_studio_groups(self, node): studio_groups = [] for xml_id in node.attrib['groups'].split(','): forbid = xml_id.startswith("!") if forbid: xml_id = xml_id[1:] group = self.env.ref(xml_id, raise_if_not_found=False) if group: studio_groups.append({ "id": group.id, "name": group.name, "display_name": group.display_name, "forbid": forbid, }) node.attrib['studio_groups'] = json.dumps(studio_groups) @api.model def set_studio_map_popup_fields(self, model, node): field_names = [field.get('name') for field in node.findall('field')] field_ids = self.env['ir.model.fields'].search([('model', '=', model), ('name', 'in', field_names)]).ids if field_ids: node.attrib['studio_map_field_ids'] = json.dumps(field_ids) @api.model def set_studio_pivot_measure_fields(self, model, node): field_names = [field.get('name') for field in node.findall('field') if field.get('type') == 'measure'] field_ids = self.env['ir.model.fields'].search([('model', '=', model), ('name', 'in', field_names)]).ids if field_ids: node.attrib['studio_pivot_measure_field_ids'] = json.dumps(field_ids) @api.model def create_automatic_views(self, res_model): """Generates automatic views for the given model depending on its fields.""" model = self.env[res_model] views = self.env['ir.ui.view'] # form, list and search: always views |= self.auto_list_view(res_model) views |= self.auto_form_view(res_model) views |= self.auto_search_view(res_model) # calendar: only if x_studio_date if 'x_studio_date' in model._fields: views |= self.auto_calendar_view(res_model) # gantt: only if x_studio_date_start & x_studio_date_stop if 'x_studio_date_start' in model._fields and 'x_studio_date_stop' in model._fields: views |= self.auto_gantt_view(res_model) # kanban: only if x_studio_stage_id if 'x_studio_stage_id' in model._fields: views |= self.auto_kanban_view(res_model) # map: only if x_studio_partner_id if 'x_studio_partner_id' in model._fields: views |= self.auto_map_view(res_model) # pivot: only if x_studio_value if 'x_studio_value' in model._fields: views |= self.auto_pivot_view(res_model) views |= self.auto_graph_view(res_model) return views def auto_list_view(self, res_model): model = self.env[res_model] rec_name = model._rec_name_fallback() fields = list() if 'x_studio_sequence' in model._fields: fields.append(E.field(name='x_studio_sequence', widget='handle')) fields.append(E.field(name=rec_name)) if 'x_studio_partner_id' in model._fields: fields.append(E.field(name='x_studio_partner_id')) if 'x_studio_user_id' in model._fields: fields.append(E.field(name='x_studio_user_id', widget='many2one_avatar_user')) if 'x_studio_company_id' in model._fields: fields.append(E.field(name='x_studio_company_id', groups='base.group_multi_company')) if 'x_studio_currency_id' in model._fields and 'x_studio_value' in model._fields: fields.append(E.field(name='x_studio_currency_id', invisible='1')) fields.append(E.field(name='x_studio_value', sum=_("Total"))) if 'x_studio_tag_ids' in model._fields: fields.append(E.field(name='x_studio_tag_ids', widget='many2many_tags', options="{'color_field': 'x_color'}")) if 'x_color' in model._fields: fields.append(E.field(name='x_color', widget='color_picker')) order = ['id desc'] if 'x_studio_sequence' in model._fields: order.append('x_studio_sequence asc') if 'x_studio_priority' in model._fields: order.append('x_studio_priority desc') order = ','.join(reversed(order)) tree_params = {} if not self.env.context.get('list_editable') else {'editable': self.env.context.get('list_editable')} tree = E.list(**tree_params, default_order=order) tree.extend(fields) arch = etree.tostring(tree, encoding='unicode', pretty_print=True) return self.create({ 'type': 'list', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('list', res_model), }) def auto_form_view(self, res_model): ir_model = self.env['ir.model']._get(res_model) model = self.env[res_model] rec_name = model._rec_name_fallback() sheet_content = list() header_content = list() if 'x_studio_stage_id' in model._fields: header_content.append(E.field(name='x_studio_stage_id', widget='statusbar', options="{'clickable': '1'}")) sheet_content.append(E.field(name='x_studio_kanban_state', widget='state_selection')) if 'x_active' in model._fields: sheet_content.append(E.widget(name='web_ribbon', text=_('Archived'), bg_color='text-bg-danger', invisible="x_active")) sheet_content.append(E.field(name='x_active', invisible='1')) if 'x_studio_image' in model._fields: sheet_content.append(E.field({'class': 'oe_avatar', 'widget': 'image', 'name': 'x_studio_image'})) title = etree.fromstring("""

""" % {'field_name': rec_name}) sheet_content.append(title) group_name = 'studio_group_' + str(uuid.uuid4())[:6] left_group = E.group(name=group_name + '_left') right_group = E.group(name=group_name + '_right') left_group_content, right_group_content = list(), list() if 'x_studio_user_id' in model._fields: right_group_content.append(E.field(name='x_studio_user_id', widget='many2one_avatar_user')) if 'x_studio_partner_id' in model._fields: left_group_content.append(E.field(name='x_studio_partner_id')) left_group_content.append(E.field(name='x_studio_partner_phone', widget='phone', options="{'enable_sms': True}")) left_group_content.append(E.field(name='x_studio_partner_email', widget='email')) if 'x_studio_currency_id' in model._fields and 'x_studio_value' in model._fields: right_group_content.append(E.field(name='x_studio_currency_id', invisible='1')) right_group_content.append(E.field(name='x_studio_value')) if 'x_studio_tag_ids' in model._fields: right_group_content.append(E.field(name='x_studio_tag_ids', widget='many2many_tags', options="{'color_field': 'x_color'}")) if 'x_studio_company_id' in model._fields: right_group_content.append(E.field(name='x_studio_company_id', groups='base.group_multi_company', options="{'no_create': True}")) if 'x_studio_date' in model._fields: left_group_content.append(E.field(name='x_studio_date')) if 'x_studio_date_start' in model._fields and 'x_studio_date_stop' in model._fields: left_group_content.append( E.field(name='x_studio_date_start', string='Dates', widget='daterange', options="{'end_date_field': 'x_studio_date_stop'}")) left_group_content.append( E.field(name='x_studio_date_stop', invisible='1')) if not left_group_content: # there is nothing in our left group; switch the groups' content # to avoid a weird looking form view left_group_content = right_group_content right_group_content = list() left_group.extend(left_group_content) right_group.extend(right_group_content) sheet_content.append(E.group(left_group, right_group, name=group_name)) if 'x_studio_notes' in model._fields: sheet_content.append(E.group(E.field(name='x_studio_notes', placeholder=_('Type down your notes here...'), nolabel='1', colspan="2"))) # if there is a '%_line_ids' field, display it as a list in a notebook lines_field = [f for f in model._fields if ("%s_line_ids" % model._name) in f] if lines_field: xml_node = E.notebook() xml_node_page = E.page({'string': 'Details', 'name': 'lines'}) xml_node_page.append(E.field(name=lines_field[0])) xml_node.insert(0, xml_node_page) sheet_content.append(xml_node) form = E.form(E.header(*header_content), E.sheet(*sheet_content, string=model._description)) if ir_model.is_mail_thread: form.append(E.chatter()) arch = etree.tostring(form, encoding='unicode', pretty_print=True) return self.create({ 'type': 'form', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('form', res_model), }) def auto_search_view(self, res_model): model = self.env[res_model] rec_name = model._rec_name_fallback() fields = list() filters = list() groupbys = list() fields.append(E.field(name=rec_name)) if isinstance(model, self.pool['mail.activity.mixin']): filters.append(E.filter( invisible="1", string=_('My Activities'), name="filter_activities_my", domain="[['activity_user_id', '=', uid]]") ) filters.append(E.separator()) filters.append(E.filter( invisible="1", string=_('Late Activities'), name='activities_overdue', domain="[('my_activity_date_deadline', '<', 'today')]") ) filters.append(E.filter( invisible="1", string=_('Today Activities'), name='activities_today', domain="[('my_activity_date_deadline', '=', 'today')]") ) filters.append(E.filter( invisible="1", string=_('Future Activities'), name='activities_upcoming_all', domain="[('my_activity_date_deadline', '>', 'today')]") ) filters.append(E.separator()) if 'x_studio_partner_id' in model._fields: fields.append(E.field(name='x_studio_partner_id', operator='child_of')) groupbys.append(E.filter(name='groupby_x_partner', string=_('Partner'), context="{'group_by': 'x_studio_partner_id'}", domain="[]")) if 'x_studio_user_id' in model._fields: fields.append(E.field(name='x_studio_user_id')) filters.append(E.filter(string=_('My %s', model._description), name='my_%s' % res_model, domain="[['x_studio_user_id', '=', uid]]")) groupbys.append(E.filter(name='groupby_x_user', string=_('Responsible'), context="{'group_by': 'x_studio_user_id'}", domain="[]")) date_filters = [] if 'x_studio_date' in model._fields: date_filters.append(E.filter(date='x_studio_date', name='studio_filter_date', string=_('Date'))) if 'x_studio_date_start' in model._fields and 'x_studio_date_stop' in model._fields: date_filters.append(E.filter(date='x_studio_date_start', name='studio_filter_date_start', string=_('Start Date'))) date_filters.append(E.filter(date='x_studio_date_stop', name='studio_filter_date_stop', string=_('End Date'))) if date_filters: filters.append(E.separator()) filters.extend(date_filters) if 'x_active' in model._fields: filters.append(E.separator()) filters.append(E.filter(string=_('Archived'), name='archived_%s' % res_model, domain="[['x_active', '=', False]]")) filters.append(E.separator()) if 'x_studio_tag_ids' in model._fields: fields.append(E.field(name='x_studio_tag_ids')) if 'x_studio_stage_id' in model._fields: groupbys.append(E.filter(name='x_studio_stage_id', string=_('Stage'), context="{'group_by': 'x_studio_stage_id'}", domain="[]")) search = E.search(*fields) search.extend(filters) if groupbys: groupby = E.group(string=_('Group By')) groupby.extend(groupbys) search.extend(groupby) arch = etree.tostring(search, encoding='unicode', pretty_print=True) return self.create({ 'type': 'search', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('search', res_model), }) def auto_calendar_view(self, res_model): model = self.env[res_model] if not 'x_studio_date' in model._fields: return self calendar = E.calendar(date_start='x_studio_date', create_name_field='x_name') arch = etree.tostring(calendar, encoding='unicode', pretty_print=True) return self.create({ 'type': 'calendar', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('calendar', res_model), }) def auto_gantt_view(self, res_model): gantt = E.gantt(date_start='x_studio_date_start', date_stop='x_studio_date_stop') arch = etree.tostring(gantt, encoding='unicode', pretty_print=True) return self.create({ 'type': 'gantt', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('gantt', res_model), }) def auto_map_view(self, res_model): field = E.field(name='x_studio_partner_id', string=_('Partner')) map_view = E.map(field, res_partner='x_studio_partner_id') arch = etree.tostring(map_view, encoding='unicode', pretty_print=True) return self.create({ 'type': 'map', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('map', res_model), }) def auto_pivot_view(self, res_model): model = self.env[res_model] fields = list() fields.append(E.field(name='x_studio_value', type='measure')) if 'x_studio_stage_id' in model._fields: fields.append(E.field(name='x_studio_stage_id', type='col')) if 'x_studio_date' in model._fields: fields.append(E.field(name='x_studio_date', type='row')) pivot = E.pivot(sample='1') pivot.extend(fields) arch = etree.tostring(pivot, encoding='unicode', pretty_print=True) return self.create({ 'type': 'pivot', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('pivot', res_model), }) def auto_graph_view(self, res_model): fields = list() fields.append(E.field(name='x_studio_value', type='measure')) fields.append(E.field(name='create_date', type='row')) graph = E.graph(sample='1') graph.extend(fields) arch = etree.tostring(graph, encoding='unicode', pretty_print=True) return self.create({ 'type': 'graph', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('graph', res_model), }) def auto_kanban_view(self, res_model): model = self.env[res_model] pre_fields = list() # fields not used in a t-field node but needed for display pre_fields.append(E.field(name='x_color')) card_main = E.main() card_header = E.div({'name': 'studio_auto_kanban_header', 'class': 'd-flex'}) card_header.append(E.field(name='x_studio_priority', widget='boolean_favorite', nolabel='1')) card_header.append(E.field({'name': 'display_name', 'class': 'h5'})) card_body = E.div({'name': 'studio_auto_kanban_body'}) card_footer = E.footer() bottom_right_div = E.div({'class': "ms-auto d-flex align-items-center"}) card_main.extend([card_header, card_body, card_footer]) if 'x_studio_sequence' in model._fields: card_header.append(E.field(name='x_studio_sequence', widget='handle', invisible='1')) if 'x_studio_kanban_state' in model._fields: status = E.field(name='x_studio_kanban_state', widget='state_selection') bottom_right_div.append(status) if 'x_studio_user_id' in model._fields: img = E.field({ 'class': 'ms-1', 'name': 'x_studio_user_id', 'widget': 'kanban.many2one_avatar_user', }) bottom_right_div.append(img) if 'x_studio_value' and 'x_studio_currency_id' in model._fields: pre_fields.append(E.field(name='x_studio_currency_id')) card_footer.append(E.field(name='x_studio_value')) if 'x_studio_tag_ids' in model._fields: card_body.append(E.field(name='x_studio_tag_ids', options="{'color_field': 'x_color'}")) kanban_card = E.t({'t-name': "card", 'class': "flex-row"}) if 'x_studio_image' in model._fields: image_field = E.field({ 'name': 'x_studio_image', 'widget': 'image', 'options': '{"zoom": true, "background": true, "preventClicks": false}' }) card_aside = E.aside() card_aside.append(image_field) kanban_card.append(card_aside) card_footer.append(bottom_right_div) kanban_card.append(card_main) kanban_menu = E.t({'t-name': "menu"}) kanban_menu.extend([ E.a({'t-if': 'widget.editable', 'role': 'menuitem', 'type': 'open', 'class': 'dropdown-item'}, _('Edit')), E.a({'t-if': 'widget.deletable', 'role': 'menuitem', 'type': 'delete', 'class': 'dropdown-item'}, _('Delete')), E.field({'widget': 'kanban_color_picker', 'name': 'x_color'}) ]) templates = E.templates(kanban_menu, kanban_card) order = 'x_studio_priority desc,x_studio_sequence asc,id desc' if 'x_studio_sequence' in model._fields else 'x_studio_priority desc,id desc' kanban = E.kanban(default_group_by='x_studio_stage_id', default_order=order, highlight_color="x_color") kanban.extend(pre_fields) if 'x_studio_value' in model._fields: progressbar = E.progressbar(field='x_studio_kanban_state', colors='{"normal": "200", "done": "success", "blocked": "danger"}', sum_field='x_studio_value') else: progressbar = E.progressbar(field='x_studio_kanban_state', colors='{"normal": "200", "done": "success", "blocked": "danger"}') kanban.append(progressbar) kanban.append(templates) arch = etree.tostring(kanban, encoding='unicode', pretty_print=True) return self.create({ 'type': 'kanban', 'model': res_model, 'arch': arch, 'name': "Default %s view for %s" % ('kanban', res_model), }) # Returns "true" if the view_id is the id of the studio view. def _is_studio_view(self): return self.xml_id.startswith('studio_customization') # Based on inherit_branding of ir_ui_view # This will add recursively the groups ids on the spec node. def _groups_branding(self, specs_tree): group_ids = self.group_ids studio = self.env.context.get('studio') check_view_ids = self.env.context.get('check_view_ids') if group_ids and (not studio or not check_view_ids): attr_value = ','.join(map(str, group_ids.ids)) for node in specs_tree.iter(tag=etree.Element): node.set('studio-view-group-ids', attr_value) # Used for studio views only. # This studio view specification will not always be available. # So, we add the groups name to find out when they will be available. # This information will be used in Studio to inform the user. def _set_groups_info(self, node, group_ids): groups = self.env['res.groups'].browse(map(int, group_ids.split(','))) view_group_names = ','.join(groups.mapped('name')) for child in node.iter(tag=etree.Element): child.set('studio-view-group-names', view_group_names) child.set('studio-view-group-ids', group_ids) # Used for studio views only. # Check if the hook node depends of groups. def _check_parent_groups(self, source, spec): node = self.locate_node(source, spec) if node is not None and node.get('studio-view-group-ids'): # Propogate group info for all children self._set_groups_info(spec, node.get('studio-view-group-ids')) # Used for studio views only. # Apply spec by spec studio view. def _apply_studio_specs(self, source, specs_tree): for spec in specs_tree.iterchildren(tag=etree.Element): if self.env.context.get('studio'): # Detect xpath base on a field added by a view with groups self._check_parent_groups(source, spec) # Here, we don't want to catch the exception. # This mechanism doesn't save the view if something goes wrong. source = super().apply_inheritance_specs(source, spec) else: # Avoid traceback if studio view and skip xpath when studio mode is off try: source = super().apply_inheritance_specs(source, spec) except ValueError: # 'locate_node' already log this error. pass return source @api.model def apply_inheritance_specs(self, source, specs_tree, pre_locate=None): # Add branding for groups if studio mode is on if self.env.context.get('studio'): self._groups_branding(specs_tree) # If this is studio view, we want to apply it spec by spec if self and self._is_studio_view(): return self._apply_studio_specs(source, specs_tree) else: # Remove branding added by '_groups_branding' before locating a node def pre_locate_studio(arch): arch.attrib.pop("studio-view-group-ids", None) return not pre_locate or pre_locate(arch) return super().apply_inheritance_specs(source, specs_tree, pre_locate=pre_locate_studio) def normalize(self, arch_to_normalize=None): if self.mode == "primary": base_arch = self.get_combined_arch() else: base_arch = self.with_context(ir_ui_view_tree_cut_off_view=self).get_combined_arch() arch_to_normalize = arch_to_normalize or self.arch if not arch_to_normalize or not base_arch: return "" return self.normalize_with_keyed_tree(base_arch=base_arch, arch_to_normalize=arch_to_normalize) @api.model def normalize_with_keyed_tree(self, base_arch, arch_to_normalize) -> str: """ Normalizes a view's arch by comparing The result from the inheritance tree (*excluding* self) TO The result from the inheritance tree (*including* self) It should yield as few simple xpaths as possible. :return: Returns the normalized studio arch """ old_tree = etree.fromstring(base_arch) KeyedXmlDiffer.assign_node_ids_for_diff(old_tree) old_str = etree.tostring(old_tree) try: new_tree = apply_inheritance_specs(old_tree, etree.fromstring(arch_to_normalize)) except ValueError as e: # Convert so edit_view() can fall back gracefully instead of crashing. raise ValidationError(str(e)) from e # Assign names to some node added to the tree, if they don't have one def on_new_node(node): if node.tag in CONTAINER_TYPES and not node.get("name"): uid = str(uuid.UUID(int=random.getrandbits(128)))[:6] node.set('name', 'studio_%s_%s' % (node.tag, uid)) # Consider views as elementary boundaries to compute xpath to and from def is_subtree(node): parent = node.getparent() return parent is None or parent.tag == "field" def get_moving_candidate_key(node): return ("field", node.get("name")) if node.tag == "field" else None return KeyedXmlDiffer(on_new_node=on_new_node, is_subtree=is_subtree, get_moving_candidate_key=get_moving_candidate_key).diff_xpath(old_str, etree.tostring(new_tree), flat=True) def copy_qweb_template(self): new = self.copy() new.inherit_id = False domain = [ ('type', '=', 'qweb'), ('key', '!=', new.key), ('key', '=like', '%s_copy_%%' % new.key), '!', ('key', '=like', '%s_copy_%%_copy_%%' % new.key)] old_copies = self.with_context(active_test=False).search_read(domain, order='key desc') nos = [int(old_copy.get('key').split('_copy_').pop()) for old_copy in old_copies] copy_no = (nos and max(nos) or 0) + 1 new_key = '%s_copy_%s' % (new.key, copy_no) cloned_templates = self.env.context.get('cloned_templates', {}) self = self.with_context(cloned_templates=cloned_templates) cloned_templates[new.key] = new_key arch_tree = self._get_view_etrees()[0] for node in arch_tree.findall(".//t[@t-call]"): tcall = node.get('t-call') if '{' in tcall: continue if tcall in self.TEMPLATE_VIEWS_BLACKLIST: continue if tcall not in cloned_templates: callview = self.search([('type', '=', 'qweb'), ('key', '=', tcall)], limit=1) if not callview: raise UserError(_("Template '%s' not found", tcall)) callview.copy_qweb_template() node.set('t-call', cloned_templates[tcall]) subtree = arch_tree.xpath("//*[@t-name]") if subtree: subtree[0].set('t-name', new_key) arch_tree = subtree[0] # copy translation from view combinations root = self view_ids = [] while True: view_ids.append(root.id) if not root.inherit_id: break root = root.inherit_id combined_views = self.browse(view_ids)._get_inheriting_views() new.write({ 'name': '%s copy(%s)' % (new.name, copy_no), 'key': new_key, 'arch_base': etree.tostring(arch_tree, encoding='unicode'), }) fields_to_ignore = (field for field in self._fields if field != 'arch_base') for view in combined_views - self: view.copy_translations(new, fields_to_ignore) return new # validation stuff def _validate_tag_button(self, node, name_manager, node_info): super()._validate_tag_button(node, name_manager, node_info) studio_approval = node.get('studio_approval') if studio_approval and self.type != 'form': self._raise_view_error(_("studio_approval attribute can only be set in form views"), node) if studio_approval and studio_approval not in ['True', 'False']: self._raise_view_error(_("Invalid studio_approval %s in button", studio_approval), node) def _contains_branded(self, node): if not self.env.context.get("studio"): return super()._contains_branded(node) if node.tag == "t" and ('t-raw' in node.attrib or 't-call' in node.attrib): return True return any(self.is_node_branded(child) for child in node.iterdescendants()) def is_node_branded(self, node): if self.env.context.get("studio"): if "data-oe-model" in node.attrib: return True if node.tag is etree.ProcessingInstruction and node.target == 'apply-inheritance-specs-node-removal': return True if any([att in ("t-out", "t-raw", "t-esc") and node.get(att) == "0" for att in node.attrib]): return True if node.tag == 't' and "t-set" in node.attrib and len(node) > 0: return True if node.tag == "t" and "t-call" in node.attrib: return True return False return super().is_node_branded(node) @api.model def save_embedded_field(self, el): # never save the content of a t-field as the DB's column value if self.env.context.get("studio"): return return super().save_embedded_field(el) class ResetViewArchWizard(models.TransientModel): """ A wizard to compare and reset views architecture. """ _inherit = "reset.view.arch.wizard" @api.model def default_get(self, fields): defaults = super().default_get(fields) if self.env.context.get("studio_report_diff"): main_view_id = defaults.get("view_id") if main_view_id: copy = get_report_view_copy(self.env["ir.ui.view"].browse(main_view_id)) if copy: defaults['reset_mode'] = 'other_view' defaults['compare_view_id'] = copy.id return defaults