# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import difflib import io from collections import defaultdict from lxml import etree from lxml.builder import E import json import uuid import random from odoo import api, models, _ from odoo.exceptions import UserError from odoo.osv import expression CONTAINER_TYPES = ( 'group', 'page', 'sheet', 'div', 'ul', 'li', 'notebook', ) class View(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_background', 'web.external_layout_boxed', 'web.external_layout_clean', 'web.external_layout_standard', ] 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. if self._context.get('studio'): for node in tree.xpath('//*[@groups]'): if node.get('groups'): self.set_studio_groups(node) if tree.tag == 'map': self.set_studio_map_popup_fields(tree.get('model_access_rights'), tree) if tree.tag == 'pivot': self.set_studio_pivot_measure_fields(tree.get('model_access_rights'), tree) return super(View, self)._postprocess_access_rights(tree) @api.model def set_studio_groups(self, node): studio_groups = [] for xml_id in node.attrib['groups'].split(','): 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 }) 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 and not 'x_studio_priority' 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', widget='monetary', options="{'currency_field': 'x_studio_currency_id'}", 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')) tree_params = {} if not self._context.get('list_editable') else {'editable': self._context.get('list_editable')} tree = E.tree(**tree_params) tree.extend(fields) arch = etree.tostring(tree, encoding='unicode', pretty_print=True) return self.create({ 'type': 'tree', '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', 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='bg-danger', attrs="{'invisible': [('x_active', '=', True)]}")) 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', widget='monetary', options="{'currency_field': 'x_studio_currency_id'}")) 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.label({'for': "x_studio_date_start"}, string='Dates')) daterangeDiv = E.div({'class': 'o_row'}) daterangeDiv.append(E.field(name='x_studio_date_start', widget='daterange', options='{"related_end_date": "x_studio_date_stop"}')) daterangeDiv.append(E.span(_(' to '))) daterangeDiv.append(E.field(name='x_studio_date_stop', widget='daterange', options='{"related_start_date": "x_studio_date_start"}')) left_group_content.append(daterangeDiv) 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'))) # 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)) chatter_widgets = list() if ir_model.is_mail_thread: chatter_widgets.append(E.field(name='message_follower_ids')) chatter_widgets.append(E.field(name='message_ids')) if ir_model.is_mail_activity: chatter_widgets.append(E.field(name='activity_ids')) if chatter_widgets: chatter_div = E.div({'class': 'oe_chatter', 'name': 'oe_chatter'}) chatter_div.extend(chatter_widgets) form.append(chatter_div) 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 '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(expand="0", 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 content_div = E.div({'class': "o_kanban_record_details"}) title = E.strong({'class': 'o_kanban_record_title', 'name': 'studio_auto_kanban_title'}) title.append(E.field(name=model._rec_name_fallback())) headers_div = E.div({'class': 'o_kanban_record_headings', 'name': 'studio_auto_kanban_headings'}) headers_div.append(E.field(name='x_studio_priority', widget='boolean_favorite', nolabel='1')) headers_div.append(title) pre_fields.append(E.field(name='x_color')) dropdown_div = E.div({'class': 'o_dropdown_kanban dropdown'}) dropdown_toggle = E.a({ 'role': 'button', 'class': 'dropdown-toggle o-no-caret btn', 'data-bs-toggle': 'dropdown', 'data-display': 'static', 'href': '#', 'aria-label': _('Dropdown Menu'), 'title': _('Dropdown Menu'), }) dropdown_toggle.append(E.span({'class': 'fa fa-ellipsis-v'})) dropdown_menu = E.div({'class': 'dropdown-menu', 'role': 'menu'}) dropdown_menu.extend([ E.a({'t-if': 'widget.editable', 'role': 'menuitem', 'type': 'edit', 'class': 'dropdown-item'},_('Edit')), E.a({'t-if': 'widget.deletable', 'role': 'menuitem', 'type': 'delete', 'class': 'dropdown-item'}, _('Delete')), E.ul({'class': 'oe_kanban_colorpicker', 'data-field': 'x_color'}) ]) dropdown_div.extend([dropdown_toggle, dropdown_menu]) top_div = E.div({'class': 'o_kanban_record_top', 'name': 'studio_auto_kanban_top'}) top_div.extend([headers_div, dropdown_div]) body_div = E.div({'class': 'o_kanban_record_body', 'name': 'studio_auto_kanban_body'}) bottom_div = E.div({'class': 'o_kanban_record_bottom', 'name': 'studio_auto_kanban_bottom'}) bottom_left_div = E.div({'class': 'oe_kanban_bottom_left', 'name': 'studio_auto_kanban_bottom_left'}) bottom_right_div = E.div({'class': 'oe_kanban_bottom_right', 'name': 'studio_auto_kanban_bottom_right'}) bottom_div.extend([bottom_left_div, bottom_right_div]) bottom_right_div.append(E.field(name='x_studio_kanban_state', widget='state_selection')) if 'x_studio_user_id' in model._fields: pre_fields.append(E.field(name='x_studio_user_id', widget="many2one_avatar_user")) unassigned_var = E.t({'t-set': 'unassigned'}) unassigned_var.append(E.t({'t-esc': "_t('Unassigned')"})) img = E.img({'t-att-src': "kanban_image('res.users', 'avatar_128', record.x_studio_user_id.raw_value)", 't-att-title': "record.x_studio_user_id.value || unassigned", 't-att-alt': "record.x_studio_user_id.value", 'class': "oe_kanban_avatar o_image_24_cover float-right"}) bottom_right_div.append(unassigned_var) bottom_right_div.append(img) content_div.extend([top_div, body_div, bottom_div]) card_div = E.div({'class': "o_kanban_record oe_kanban_global_click o_kanban_record_has_image_fill", 'color': 'x_color'}) if 'x_studio_value' and 'x_studio_currency_id' in model._fields: pre_fields.append(E.field(name='x_studio_currency_id')) bottom_left_div.append(E.field(name='x_studio_value', widget='monetary', options="{'currency_field': 'x_studio_currency_id'}")) if 'x_studio_tag_ids' in model._fields: body_div.append(E.field(name='x_studio_tag_ids', options="{'color_field': 'x_color'}")) if 'x_studio_image' in model._fields: image_field = E.field({ 'class': 'o_kanban_image_fill_left', 'name': 'x_studio_image', 'widget': 'image', 'options': '{"zoom": true, "background": true, "preventClicks": false}' }) card_div.append(image_field) card_div.append(content_div) kanban_box = E.t(card_div, {'t-name': "kanban-box"}) templates = E.templates(kanban_box) 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) 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): groups_id = self.groups_id studio = self.env.context.get('studio') check_view_ids = self.env.context.get('check_view_ids') if groups_id and (not studio or not check_view_ids): attr_value = ','.join(map(str, groups_id.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._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(View, self).apply_inheritance_specs(source, spec) else: # Avoid traceback if studio view and skip xpath when studio mode is off try: source = super(View, self).apply_inheritance_specs(source, spec) except ValueError: # 'locate_node' already log this error. pass return source def apply_inheritance_specs(self, source, specs_tree): # Add branding for groups if studio mode is on if self._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 pre_locate = lambda arch: arch.attrib.pop("studio-view-group-ids", None) return super(View, self).apply_inheritance_specs(source, specs_tree, pre_locate=pre_locate) def normalize(self): """ Normalizes the studio arch by comparing the studio view to the base view and combining as many xpaths as possible in order to have a more compact final view Returns the normalized studio arch """ # Beware ! By its reasoning, this function assumes that the view you # want to normalize is the last one to be applied on its root view. # This could be improved by deactivating all views that would be applied # after this one when calling the get_combined_arch to get the old_view # then re-enabling them all afterwards. def is_moved(node): """ Helper method that determines if a node is a moved field.""" return node.tag == 'field' and node.get('name') in moved_fields # Fetch the root view root_view = self while root_view.mode != 'primary': root_view = root_view.inherit_id parser = etree.XMLParser(remove_blank_text=True) new_view = root_view.get_combined_arch() # Get the result of the xpath applications without this view self.active = False old_view = root_view.get_combined_arch() self.active = True # The parent data tag is missing from get_combined_arch new_view_tree = etree.Element('data') new_view_tree.append(etree.parse(io.StringIO(new_view), parser).getroot()) old_view_tree = etree.Element('data') old_view_tree.append(etree.parse(io.StringIO(old_view), parser).getroot()) new_view_arch_string = self._stringify_view(new_view_tree) old_view_arch_string = self._stringify_view(old_view_tree) diff = difflib.ndiff(old_view_arch_string.split('\n'), new_view_arch_string.split('\n')) # Format of difflib.ndiff output is: # unchanged # - removed # + added # ? details # # unchanged old_view_iterator = old_view_tree.iter() new_view_iterator = new_view_tree.iter() # Determine which fields have moved. This information will be used to # compute the second diff because the moved nodes must appear in the # diff (see @stringify_node). removed_fields = {} added_fields = {} moved_fields = {} changes = { '-': [], '+': [] } moving_boundary = None node = None def store_field(operation): if operation == '-': node = next(old_view_iterator) if node.tag == 'field': removed_fields[node.get('name')] = node elif operation == '+': node = next(new_view_iterator) if node.tag == 'field': added_fields[node.get('name')] = node for line in diff: if line.strip() and not line.startswith('?'): if line.startswith('-') or line.startswith('+'): operation, line = line.split(' ', 1) nodes = changes[operation] if line.endswith('[@closed]') and nodes and nodes[-1] + '[@closed]' == line: # This is the closing of a node we were operating on. # It is not a candidate for moving boundary. nodes.pop() elif moving_boundary and moving_boundary != operation: # We are already in a moving boundary mode. # Look into the corresponding nodes for a match. nodes = changes.get(moving_boundary) if nodes and line == nodes[0]: # The node matches the current moving boundary. # We can stop watching this node. nodes.pop(0) if not nodes: # The moving boundary is over as we found # all its nodes twice. moving_boundary = None if not line.endswith('[@closed]'): # If we are operating on a field, let's store it. store_field(operation) elif line.endswith('[@closed]'): # We are operating on the closing of a node that # we are not not operating on ! Moving boundary ! nodes.append(line) moving_boundary = operation else: # Store this node to match when we close it. nodes.append(line) # If we are operating on a field, let's store it. store_field(operation) else: # This node seemingly has not moved. if not line.endswith('[@closed]'): # Only the nodes can be moved, we ignore the closings old_node = next(old_view_iterator) node = next(new_view_iterator) # If we are in moving boundary mode, then this node # definitely moved, since the boundary moved around it ! if moving_boundary and node.tag == 'field': # Only fields are currently supported. removed_fields[node.get('name')] = old_node added_fields[node.get('name')] = node # Look at the fields we decided to watch. If they were both # removed and added, it means they have been moved. for name in removed_fields: if name in added_fields: moved_fields[name] = { 'old': removed_fields[name], 'new': added_fields[name], } # Recreate the trees as they have been modified during the first processing new_view_tree = etree.Element('data') new_view_tree.append(etree.parse(io.StringIO(new_view), parser).getroot()) old_view_tree = etree.Element('data') old_view_tree.append(etree.parse(io.StringIO(old_view), parser).getroot()) old_view_iterator = old_view_tree.iter() new_view_iterator = new_view_tree.iter() new_view_arch_string = self._stringify_view(new_view_tree, moved_fields) old_view_arch_string = self._stringify_view(old_view_tree) diff = difflib.ndiff(old_view_arch_string.split('\n'), new_view_arch_string.split('\n')) # Keep track of nameless elements with more than 1 occurrence nameless_count = defaultdict(int) for node in new_view_tree.iter(): if not node.get('name'): nameless_count[node.tag] += 1 arch = etree.Element('data') xpath = etree.Element('xpath') for line in diff: # Ignore details lines and [@closed] that are used so diff has correct order if line.strip() and not line.startswith('?') and not line.endswith('[@closed]'): line = line.replace('[@moved]', '') if line.startswith('-'): node = next(old_view_iterator) if node.tag == 'attribute': continue if is_moved(node) or \ any(is_moved(x) for x in node.iterancestors()): # nothing to do here, the node will be moved in the '+' continue # If we are already writing an xpath, we need to either # close it or ignore this line if xpath.get('expr'): # Maybe we are already removing the parent of this # node so this one will be removed automatically current_xpath_target = next(iter(old_view_tree.xpath('.' + xpath.get('expr'))), None) if xpath.get('position') == 'replace' and \ current_xpath_target in node.iterancestors(): continue # If we are already adding stuff just before this node, # we could as well replace it directly by what we want to add # Also take care not to close the xpath is we are still # in the attributes section of a given node elif ((node.tag != 'attributes' and xpath.get('position') != 'after') or (node.tag == 'attributes' and xpath.get('position') != 'attributes')): # Consecutive removals need different xpath xpath = self._close_and_get_new(arch, xpath) xpath.attrib['expr'] = self._node_to_xpath(node) if node.tag == 'attributes': xpath.attrib['position'] = 'attributes' # The attribute is removed etree.SubElement(xpath, 'attribute', {'name': node.get('name')}) else: xpath.attrib['position'] = 'replace' elif line.startswith('+'): node = next(new_view_iterator) # if there is more than one element with this tag and it doesn't have a way # to identify itself, give it a name if (node.tag in CONTAINER_TYPES and nameless_count[node.tag] > 1 and not node.get('name')): uid = str(uuid.UUID(int=random.getrandbits(128)))[:6] node.attrib['name'] = 'studio_%s_%s' % (node.tag, uid) if node.tag == 'attributes': continue if any(is_moved(x) for x in node.iterancestors()): # moved attributes will be computed afterwards because # the move xpaths don't support children # (see @get_node_attributes_diff) continue # The node for which this is the attribute may have been # added by studio, in which case we don't need a new # xpath to handle it properly if node.tag == 'attribute' and self._get_node_from_xpath(xpath, node.getparent().getparent(), moved_fields) is not None: continue anchor_node = self._get_anchor_node(arch, xpath, node, moved_fields) if anchor_node.tag == 'xpath' and not anchor_node.get('expr'): # If the current xpath was not compatible, it has been # closed and a new one has been generated xpath = anchor_node xpath.attrib['expr'], xpath.attrib['position'] = self._closest_node_to_xpath(node, old_view_tree, moved_fields) if node.tag == 'field' and node.get('name') in moved_fields: # manually replace the node by the `move` xpath node = etree.Element('xpath', { 'expr': self._node_to_xpath(moved_fields[node.get('name')]['old']), 'position': 'move', }) self._clone_and_append_to(node, anchor_node) else: old_node = next(old_view_iterator) next(new_view_iterator) # This is an unchanged line, if an xpath is ungoing, close it. if old_node.tag not in ['attribute', 'attributes']: if xpath.get('expr'): xpath = self._close_and_get_new(arch, xpath) # Append last remaining xpath if needed if xpath.get('expr') is not None: self._add_xpath_to_arch(arch, xpath) def get_node_attributes_diff(node1, node2): """ Computes the differences of attributes between two nodes.""" diff = {} for attr in node1.attrib: if attr not in node2.attrib: diff[attr] = '' elif node1.attrib[attr] != node2.attrib[attr]: diff[attr] = node2.attrib[attr] for attr in dict(node2.attrib).keys() - dict(node1.attrib).keys(): diff[attr] = node2.attrib[attr] return diff # Add xpath attributes for moved fields for f in moved_fields: old_node = moved_fields[f]['old'] new_node = moved_fields[f]['new'] attrs_diff = get_node_attributes_diff(old_node, new_node) if len(attrs_diff): xpath = etree.Element('xpath') xpath.attrib['expr'] = self._node_to_xpath(new_node) xpath.attrib['position'] = 'attributes' # alphabetically sort attributes by name node_attributes = sorted(attrs_diff.keys()) for attr in node_attributes: etree.SubElement(xpath, 'attribute', { 'name': attr, }).text = attrs_diff[attr] self._add_xpath_to_arch(arch, xpath) normalized_arch = etree.tostring(self._indent_tree(arch), encoding='unicode') if len(arch) else u'' return normalized_arch def _close_and_get_new(self, arch, xpath): self._add_xpath_to_arch(arch, xpath) return etree.Element('xpath') def _get_anchor_node(self, arch, xpath, node, moved_fields): """ Check if a node can be merged inside an existing xpath Returns True if the node can be fit inside the given xpath, False otherwise """ # Not compatible is either: # - position != attributes when node is an attribute # - position == attributes when node is not an attribute # - the node we want to add is not contiguous with the current xpath, # which means the current xpath is not empty and the node preceding # the one we we want to add is not in the xpath if not len(xpath): return xpath if xpath.get('position') == 'attributes': if node.tag == 'attribute': return xpath else: return self._close_and_get_new(arch, xpath) # If the preceding node or the parent is in the current xpath, we can append to it anchor_node = node.getprevious() if (anchor_node is not None and anchor_node.tag not in ['attribute', 'attributes']): studio_previous_node = self._get_node_from_xpath(xpath, anchor_node, moved_fields) if studio_previous_node is not None: return studio_previous_node.getparent() else: return self._close_and_get_new(arch, xpath) else: anchor_node = node.getparent() if anchor_node.tag == 'attributes': anchor_node = anchor_node.getparent() if node.tag == 'field' and node.get('name') in moved_fields: # Parent node of a moved field xpath must be the xpath of the new targeted position return self._close_and_get_new(arch, xpath) studio_parent_node = self._get_node_from_xpath(xpath, anchor_node, moved_fields) if studio_parent_node is not None: return studio_parent_node else: return self._close_and_get_new(arch, xpath) def _get_node_from_xpath(self, xpath, node, moved_fields): """ Get a node from within an xpath if it exists Returns a node if it exists within the given xpath, None otherwise """ for n in reversed(list(xpath.iter())): if n.tag == node.tag and n.attrib == node.attrib and n.text == node.text: return n # Find the node if it had been moved (only fields can be moved) if node.tag == 'field': # Only fields are currently supported name = node.get('name') if n.get('position') == 'move' and name in moved_fields: # the moved nodes are set as xpath so in order to match the # nodes we need to compare both xpath old_node = moved_fields.get(name)['old'] if n.get('expr') == self._node_to_xpath(old_node): return n return None def _add_xpath_to_arch(self, arch, xpath): """ Appends the xpath to the arch if the xpath's position != 'replace' (deletion), otherwise it is prepended to the arch. This is done because when moving an existing field somewhere before its original position it will append a replace xpath and then append the existing field xpath, effictively removing the one just added and showing the one that existed before. """ # TODO: Only add attributes if the xpath has children if xpath.get('position') == 'replace': arch.insert(0, xpath) else: arch.append(xpath) def _clone_and_append_to(self, node, parent_node): """ Clones the passed-in node and appends it to the passed-in parent_node Returns the parent_node with the newly-appended node """ if node.tag is etree.Comment: # For comments, node.tag is the constructor of Comment nodes elem = parent_node.append(etree.Comment(node.text)) else: # This doesn't copy the children, but we don't truly # care, since children will be another diff line elem = etree.SubElement(parent_node, node.tag, node.attrib) elem.text = node.text elem.tail = node.tail return elem def _node_to_xpath(self, target_node, node_context=None): """ Creates and returns a relative xpath that points to target_node """ if target_node.tag == 'attribute': target_node = target_node.getparent().getparent() elif target_node.tag == 'attributes': target_node = target_node.getparent() root = target_node.getroottree() el_name = target_node.get('name') if el_name and root.xpath('count(//*[@name="%s"])' % el_name) == 1: # there are cases when there are multiple instances of the same # named element in the same view, but for different reasons # i.e.: sub-views and kanban views expr = '//%s' % self._identify_node(target_node) else: ancestors = [ self._identify_node(n, node_context) for n in target_node.iterancestors() if n.getparent() is not None ] node = self._identify_node(target_node, node_context) if ancestors: expr = '//%s/%s' % ('/'.join(reversed(ancestors)), node) else: # There are cases where there might not be any ancestors # like in a brand new gantt or calendar view, if that's the # case then just give the identified node expr = '//%s' % node return expr def _identify_node(self, node, node_context=None): """ Creates and returns an identifier for the passed-in node either by using its name attribute (relative identifier) or by getting the number of preceding sibling elements (absolute identifier) """ # Some nodes may have a name which is not id-like, but is a technical attribute # that won't be unique named_tags = ['field', 'button'] # 0. Identify "regular" nodes by their name: name here is id-like if node.get('name') and node.tag not in named_tags: node_str = '%s[@name=\'%s\']' % (node.tag, node.get('name')) return node_str same_tag_prev_siblings = list(node.itersiblings(tag=node.tag, preceding=True)) # Otherwise, we'd have to compute the absolute path of the node along 2 cases # 1. Current node does not have a name or doesn't need one if not node.get('name') or node.tag not in named_tags: # Only consider same tag siblings that don't have a name either colliding_prev_siblings = [ sibling for sibling in same_tag_prev_siblings if ('name' not in sibling.attrib) ] node_str = '%s' % (node.tag,) # Only count no name node to avoid conflict with other studio change if len(colliding_prev_siblings) != len(same_tag_prev_siblings): node_str += '[not(@name)]' # We need to add 1 to the number of previous siblings to get the # position index of the node because these indices start at 1 in an xpath context. node_str += '[%s]' % (len(colliding_prev_siblings) + 1,) return node_str # 2. Current node has a name which is not id-like # There can be more than one node in that case if node.get('name') and node.tag in named_tags: # Only consider same tag siblings that do have the same name colliding_prev_siblings = [ sibling for sibling in same_tag_prev_siblings if (node.get('name') == sibling.get('name')) ] node_str = '%s[@name=\'%s\']' % ( node.tag, node.get('name'), ) if len(colliding_prev_siblings): node_str += '[%s]' % (len(colliding_prev_siblings) + 1,) return node_str def _closest_node_to_xpath(self, node, old_view, moved_fields, node_context=None): """ Returns an expr and position for the node closest to the passed-in node so that it may be used as a target. The closest node will be one adjacent to this one and that has an identifiable name (name attr), this can be it's next sibling, previous sibling or its parent. If none is found, the method will fallback to next/previous sibling or parent even if they don't have an identifiable name, in which case an absolute xpath expr will be generated """ def _is_valid_anchor(target_node): if (target_node is None) or not isinstance(target_node.tag, str): return None if target_node.tag in ['attribute', 'attributes']: return None if target_node.tag == 'field' and target_node.get('name') in moved_fields: # a moved field cannot be used as anchor return None target_node_expr = '.' + self._node_to_xpath(target_node, node_context) return bool(old_view.xpath(target_node_expr)) nxt = node.getnext() prev = node.getprevious() if node.tag == 'attribute': # Invisible element target_node = node.getparent().getparent() # /node/attributes/attribute reanchor_position = 'attributes' elif node.tag == 'page': # a page is always put inside its corresponding notebook target_node = node.getparent() reanchor_position = 'inside' else: # Visible element while prev is not None or nxt is not None: # Try to anchor onto the closest adjacent element if _is_valid_anchor(prev): target_node = prev reanchor_position = 'after' break elif _is_valid_anchor(nxt): target_node = nxt reanchor_position = 'before' break else: if prev is not None: prev = prev.getprevious() if nxt is not None: nxt = nxt.getnext() else: # Reanchor on first parent, but the "inside" will make it last child target_node = node.getparent() reanchor_position = 'inside' reanchor_expr = self._node_to_xpath(target_node, node_context) return reanchor_expr, reanchor_position def _stringify_view(self, arch, moved_fields=None): return self._stringify_node('', arch, moved_fields) def _stringify_node(self, ancestor, node, moved_fields=None): """ Converts a node into its string representation Example: from: to: "/field[@name='color']\n" Returns the stringified node """ result = '' node_string = ancestor + '/' if node.tag is etree.Comment: node_string += 'comment' else: node_string += node.tag if node.get('name') and node.get('name').strip(): node_string += '[@name=%s]' % node.get('name').strip().replace('\n', ' ') if node.text and node.text.strip(): node_string += '[@text=%s]' % node.text.strip().replace('\n', ' ') if node.tail and node.tail.strip(): node_string += '[@tail=%s]' % node.tail.strip().replace('\n', ' ') if node.tag == 'field' and moved_fields and node.get('name') in moved_fields: # make sure we don't tagged fields which are not really moved # (i.e. if the field appears more than once in the view) if self._node_to_xpath(node) == self._node_to_xpath(moved_fields[node.get('name')]['new']): # ensure that moved fields do appear in the final diff # (if they don't, it's not possible to reconstruct `move` xpaths) node_string += '[@moved]' result += node_string + '\n' self._generate_node_attributes(node) for child in node.iterchildren(): result += self._stringify_node(node_string, child, moved_fields) # have a end marker so same location changes are not mixed result += node_string + '[@closed]' + '\n' return result def _generate_node_attributes(self, node): """ Generates attributes wrapper elements for each of the node's attributes and prepend them as first children of the node """ if node.tag not in ('attribute', 'attributes'): # node.items() gives a list of tuples, each tuple representing # a key, value pair for attributes node_attributes = sorted(node.items(), key=lambda i: i[0], reverse=True) # inverse alphabetically sort attributes by name if len(node_attributes): for attr in node_attributes: attributes = etree.Element('attributes', { 'name': attr[0], }) etree.SubElement(attributes, 'attribute', { 'name': attr[0], }).text = attr[1] node.insert(0, attributes) def _indent_tree(self, elem, level=0): """ The lxml library doesn't pretty_print xml tails, this method aims to solve this. Returns the elem with properly indented text and tail """ # See: http://lxml.de/FAQ.html#why-doesn-t-the-pretty-print-option-reformat-my-xml-output # Below code is inspired by http://effbot.org/zone/element-lib.htm#prettyprint i = "\n" + level * " " if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + " " if not elem.tail or not elem.tail.strip(): elem.tail = i for subelem in elem: self._indent_tree(subelem, level + 1) if not subelem.tail or not subelem.tail.strip(): subelem.tail = i else: if level and (not elem.tail or not elem.tail.strip()): elem.tail = i return elem 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', 'not like', '%s_copy_%%_copy_%%' % new.key)] old_copies = self.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 = etree.fromstring(self._read_template(self.id)) 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).with_context(check_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).with_context(from_copy_translation=True): 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)