Files
enterprise-addons/web_studio/models/ir_ui_view.py
T

799 lines
36 KiB
Python

# -*- 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("""
<div class="oe_title">
<h1>
<field name="%(field_name)s" required="1" placeholder="Name..."/>
</h1>
</div>
""" % {'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