799 lines
36 KiB
Python
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
|