Files
tradon/ir/ui/view.py
2025-12-26 13:11:43 +00:00

798 lines
26 KiB
Python
Executable File

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import json
import os
import logging
from lxml import etree
from sql import Literal, Null
from trytond.cache import Cache, MemoryCache
from trytond.i18n import gettext
from trytond.model import Index, ModelSQL, ModelView, fields, sequence_ordered
from trytond.model.exceptions import ValidationError
from trytond.pool import Pool
from trytond.pyson import PYSON, Bool, Eval, If, PYSONDecoder
from trytond.rpc import RPC
from trytond.tools import file_open
from trytond.transaction import Transaction
from trytond.wizard import Button, StateView, Wizard
from ..action import DomainError, ViewError
logger = logging.getLogger(__name__)
class XMLError(ValidationError):
pass
class View(
fields.fmany2one(
'model_ref', 'model', 'ir.model,model', "Model",
ondelete='CASCADE'),
fields.fmany2one(
'field_children', 'field_childs,model',
'ir.model.field,name,model',
"Children Field",
domain=[
('model', '=', Eval('model')),
],
states={
'invisible': Eval('type') != 'tree',
}),
fields.fmany2one(
'module_ref', 'module', 'ir.module,name', "Module",
readonly=True, ondelete='CASCADE'),
sequence_ordered('priority', "Priority"), ModelSQL, ModelView):
"View"
__name__ = 'ir.ui.view'
model = fields.Char('Model', states={
'required': Eval('type') != 'board',
})
type = fields.Selection([
(None, ''),
('tree', 'Tree'),
('form', 'Form'),
('graph', 'Graph'),
('calendar', 'Calendar'),
('board', 'Board'),
('list-form', "List Form"),
], 'View Type',
domain=[
If(Bool(Eval('inherit')),
('type', '=', None),
('type', '!=', None)),
],
depends=['inherit'])
type_string = type.translated('type')
data = fields.Text('Data')
name = fields.Char('Name', states={
'invisible': ~(Eval('module') & Eval('name')),
}, depends=['module'], readonly=True)
arch = fields.Function(fields.Text('View Architecture', states={
'readonly': Bool(Eval('name')),
}, depends=['name']), 'get_arch', setter='set_arch')
basis = fields.Function(fields.Boolean("Basis"), 'get_basis')
inherit = fields.Many2One('ir.ui.view', 'Inherited View',
ondelete='CASCADE')
extensions = fields.One2Many(
'ir.ui.view', 'inherit', "Extensions",
filter=[
('basis', '=', False),
],
domain=[
('model', '=', Eval('model')),
('type', '=', None),
],
states={
'invisible': ~Eval('type'),
},
order=[('id', None)])
field_childs = fields.Char('Children Field', states={
'invisible': Eval('type') != 'tree',
}, depends=['type'])
module = fields.Char('Module', states={
'invisible': ~Eval('module'),
}, readonly=True)
domain = fields.Char('Domain', states={
'invisible': ~Eval('inherit'),
}, depends=['inherit'])
_get_rng_cache = MemoryCache('ir_ui_view.get_rng', context=False)
_view_get_cache = Cache('ir_ui_view.view_get')
__module_index = None
@classmethod
def __setup__(cls):
super(View, cls).__setup__()
table = cls.__table__()
cls.priority.required = True
cls.__rpc__['view_get'] = RPC(instantiate=0, cache=dict(days=1))
cls._buttons.update({
'show': {
'readonly': Eval('type') != 'form',
'invisible': ~Eval('basis', False),
'depends': ['type', 'basis'],
},
})
cls._sql_indexes.update({
Index(table,
(table.model, Index.Equality()),
(table.inherit, Index.Equality())),
Index(
table,
(table.id, Index.Equality()),
(table.inherit, Index.Equality())),
})
@staticmethod
def default_priority():
return 16
@staticmethod
def default_module():
return Transaction().context.get('module') or ''
def get_basis(self, name):
return not self.inherit or self.model != self.inherit.model
@classmethod
def domain_basis(cls, domain, tables):
table, _ = tables[None]
if 'inherit' not in tables:
inherit = cls.__table__()
tables['inherit'] = {
None: (inherit, table.inherit == inherit.id),
}
else:
inherit, _ = tables['inherit'][None]
expression = (table.inherit == Null) | (table.model != inherit.model)
_, operator, value = domain
if operator in {'=', '!='}:
if (operator == '=') != value:
expression = ~expression
elif operator in {'in', 'not in'}:
if True in value and False not in value:
pass
elif False in value and True not in value:
expression = ~expression
else:
expression = Literal(True)
else:
expression = Literal(True)
return expression
def get_rec_name(self, name):
return '%s (%s)' % (
self.model_ref.rec_name,
self.inherit.rec_name if self.inherit else self.type_string)
@classmethod
def search_rec_name(cls, name, clause):
return [('model_ref.rec_name', *clause[1:])]
@classmethod
@ModelView.button_action('ir.act_view_show')
def show(cls, views):
pass
@classmethod
def get_rng(cls, type_):
key = (cls.__name__, type_)
rng = cls._get_rng_cache.get(key)
if rng is None:
if type_ == 'list-form':
type_ = 'form'
rng_name = os.path.join(os.path.dirname(__file__), type_ + '.rng')
with open(rng_name, 'rb') as fp:
rng = etree.fromstring(fp.read())
cls._get_rng_cache.set(key, rng)
return rng
@property
def rng_type(self):
if self.inherit:
return self.inherit.rng_type
return self.type
@classmethod
def validate(cls, views):
super(View, cls).validate(views)
cls.check_xml(views)
@classmethod
def check_xml(cls, views):
"Check XML"
for view in views:
if not view.arch:
continue
xml = view.arch.strip()
if not xml:
continue
tree = etree.fromstring(xml)
if hasattr(etree, 'RelaxNG'):
validator = etree.RelaxNG(etree=cls.get_rng(view.rng_type))
if not validator.validate(tree):
error_log = '\n'.join(map(str,
validator.error_log.filter_from_errors()))
raise XMLError(
gettext('ir.msg_view_invalid_xml', name=view.rec_name),
error_log)
root_element = tree.getroottree().getroot()
# validate pyson attributes
validates = {
'states': fields.states_validate,
}
def encode(element):
for attr in ('states', 'domain', 'spell'):
if not element.get(attr):
continue
try:
value = PYSONDecoder().decode(element.get(attr))
validates.get(attr, lambda a: True)(value)
except Exception as e:
error_log = '%s: <%s %s="%s"/>' % (
e, element.get('id') or element.get('name'), attr,
element.get(attr))
raise XMLError(
gettext(
'ir.msg_view_invalid_xml', name=view.rec_name),
error_log) from e
for child in element:
encode(child)
encode(root_element)
def get_arch(self, name):
value = None
if self.name and self.module:
path = os.path.join(self.module, 'view', self.name + '.xml')
try:
with file_open(path,
subdir='modules', mode='r', encoding='utf-8') as fp:
value = fp.read()
except IOError:
pass
if not value:
value = self.data
return value
@classmethod
def set_arch(cls, views, name, value):
cls.write(views, {'data': value})
@classmethod
def delete(cls, views):
super(View, cls).delete(views)
# Restart the cache
cls._view_get_cache.clear()
ModelView._fields_view_get_cache.clear()
@classmethod
def create(cls, vlist):
views = super(View, cls).create(vlist)
# Restart the cache
cls._view_get_cache.clear()
ModelView._fields_view_get_cache.clear()
return views
@classmethod
def write(cls, views, values, *args):
super(View, cls).write(views, values, *args)
# Restart the cache
cls._view_get_cache.clear()
ModelView._fields_view_get_cache.clear()
@property
def _module_index(self):
from trytond.modules import create_graph, get_modules
if self.__class__.__module_index is None:
graph = create_graph(get_modules(with_test=Pool.test))
modules = [m.name for m in graph]
self.__class__.__module_index = {
m: i for i, m in enumerate(reversed(modules))}
return self.__class__.__module_index
def view_get(self, model=None):
key = (self.id, model)
result = self._view_get_cache.get(key)
if result:
return result
if self.inherit:
if self.inherit.model == model:
return self.inherit.view_get(model=model)
else:
arch = self.inherit.view_get(self.inherit.model)['arch']
else:
arch = self.arch
views = self.__class__.search(['OR', [
('inherit', '=', self.id),
('model', '=', model),
], [
('id', '=', self.id),
('inherit', '!=', None),
],
])
views.sort(
key=lambda v: self._module_index.get(v.module, -1), reverse=True)
parser = etree.XMLParser(remove_comments=True, resolve_entities=False)
# logger.info("ARCH:%s",arch)
# logger.info("MODEL:%s",model)
# logger.info("PARSER:%s",etree)
tree = etree.fromstring(arch, parser=parser)
decoder = PYSONDecoder({'context': Transaction().context})
for view in views:
if view.domain and not decoder.decode(view.domain):
continue
if not view.arch or not view.arch.strip():
continue
tree_inherit = etree.fromstring(view.arch, parser=parser)
tree = self.inherit_apply(tree, tree_inherit)
if model:
root = tree.getroottree().getroot()
self._translate(root, model, Transaction().language)
arch = etree.tostring(tree, encoding='utf-8').decode('utf-8')
result = {
'type': self.rng_type,
'view_id': self.id,
'arch': arch,
'field_childs': self.field_childs,
}
self._view_get_cache.set(key, result)
return result
@classmethod
def inherit_apply(cls, tree, inherit):
root_inherit = inherit.getroottree().getroot()
for element in root_inherit:
expr = element.get('expr')
targets = tree.xpath(expr)
assert targets, "No elements found for expression %r" % expr
for target in targets:
position = element.get('position', 'inside')
new_tree = getattr(cls, '_inherit_apply_%s' % position)(
tree, element, target)
if new_tree:
tree = new_tree
return tree
@classmethod
def _inherit_apply_replace(cls, tree, element, target):
parent = target.getparent()
if parent is None:
tree, = element
return tree
cls._inherit_apply_after(tree, element, target)
parent.remove(target)
@classmethod
def _inherit_apply_replace_attributes(cls, tree, element, target):
child, = element
for attr in child.attrib:
target.set(attr, child.get(attr))
@classmethod
def _inherit_apply_inside(cls, tree, element, target):
target.extend(list(element))
@classmethod
def _inherit_apply_after(cls, tree, element, target):
parent = target.getparent()
next_ = target.getnext()
if next_ is not None:
for child in element:
index = parent.index(next_)
parent.insert(index, child)
else:
parent.extend(list(element))
@classmethod
def _inherit_apply_before(cls, tree, element, target):
parent = target.getparent()
for child in element:
index = parent.index(target)
parent.insert(index, child)
@classmethod
def _translate(cls, element, model, language):
pool = Pool()
Translation = pool.get('ir.translation')
for attr in ['string', 'sum', 'confirm', 'help']:
if element.get(attr):
translation = Translation.get_source(
model, 'view', language, element.get(attr))
if translation:
element.set(attr, translation)
for child in element:
cls._translate(child, model, language)
class ShowViewStart(ModelView):
'Show view'
__name__ = 'ir.ui.view.show.start'
__no_slots__ = True
class ShowView(Wizard):
'Show view'
__name__ = 'ir.ui.view.show'
class ShowStateView(StateView):
def __init__(self, model_name, buttons):
StateView.__init__(self, model_name, None, buttons)
def get_view(self, wizard, state_name):
pool = Pool()
View = pool.get('ir.ui.view')
view_id = Transaction().context.get('active_id')
if not view_id:
# Set type to please ModuleTestCase.test_wizards
return {'type': 'form'}
view = View(view_id)
Model = pool.get(view.model)
return Model.fields_view_get(view_id=view.id)
def get_defaults(self, wizard, state_name, fields):
return {}
start = ShowStateView('ir.ui.view.show.start', [
Button('Close', 'end', 'tryton-close', default=True),
])
class ViewTreeWidth(
fields.fmany2one(
'model_ref', 'model', 'ir.model,model', "Model",
required=True, ondelete='CASCADE'),
fields.fmany2one(
'field_ref', 'field,model', 'ir.model.field,name,model', "Field",
required=True, ondelete='CASCADE',
domain=[
('model', '=', Eval('model')),
]),
ModelSQL, ModelView):
"View Tree Width"
__name__ = 'ir.ui.view_tree_width'
model = fields.Char('Model', required=True)
field = fields.Char('Field', required=True)
user = fields.Many2One('res.user', 'User', required=True,
ondelete='CASCADE')
width = fields.Integer('Width', required=True)
@classmethod
def __setup__(cls):
super(ViewTreeWidth, cls).__setup__()
table = cls.__table__()
cls.__rpc__.update({
'set_width': RPC(readonly=False),
})
cls._sql_indexes.add(
Index(
table,
(table.user, Index.Equality()),
(table.model, Index.Equality()),
(table.field, Index.Equality())))
def get_rec_name(self, name):
return f'{self.field_ref.rec_name} @ {self.model_ref.rec_name}'
@classmethod
def search_rec_name(cls, name, clause):
operator = clause[1]
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('model_ref.rec_name', *clause[1:]),
('field_ref.rec_name', *clause[1:]),
]
@classmethod
def delete(cls, records):
ModelView._fields_view_get_cache.clear()
super(ViewTreeWidth, cls).delete(records)
@classmethod
def create(cls, vlist):
res = super(ViewTreeWidth, cls).create(vlist)
ModelView._fields_view_get_cache.clear()
return res
@classmethod
def write(cls, records, values, *args):
super(ViewTreeWidth, cls).write(records, values, *args)
ModelView._fields_view_get_cache.clear()
@classmethod
def set_width(cls, model, fields):
'''
Set width for the current user on the model.
fields is a dictionary with key: field name and value: width.
'''
records = cls.search([
('user', '=', Transaction().user),
('model', '=', model),
('field', 'in', list(fields.keys())),
])
cls.delete(records)
to_create = []
for field in list(fields.keys()):
to_create.append({
'model': model,
'field': field,
'user': Transaction().user,
'width': fields[field],
})
if to_create:
cls.create(to_create)
class ViewTreeOptional(ModelSQL, ModelView):
"View Tree Optional"
__name__ = 'ir.ui.view_tree_optional'
view_id = fields.Many2One(
'ir.ui.view', "View ID", required=True, ondelete='CASCADE')
user = fields.Many2One(
'res.user', "User", required=True, ondelete='CASCADE')
field = fields.Char("Field", required=True)
value = fields.Boolean("Value")
@classmethod
def __setup__(cls):
super().__setup__()
cls.__rpc__.update({
'set_optional': RPC(readonly=False),
})
table = cls.__table__()
cls._sql_indexes.add(
Index(
table,
(table.user, Index.Equality()),
(table.view_id, Index.Equality())))
@classmethod
def validate_fields(cls, records, fields_names):
super().validate_fields(records, fields_names)
cls.check_view_id(records, fields_names)
@classmethod
def check_view_id(cls, records, fields_names=None):
if fields_names and 'view_id' not in fields_names:
return
for record in records:
if record.view_id and record.view_id.rng_type != 'tree':
raise ViewError(gettext(
'ir.msg_view_tree_optional_type',
view=record.view_id.rec_name))
@classmethod
def create(cls, vlist):
records = super().create(vlist)
ModelView._fields_view_get_cache.clear()
return records
@classmethod
def write(cls, *args):
super().write(*args)
ModelView._fields_view_get_cache.clear()
@classmethod
def delete(cls, records):
ModelView._fields_view_get_cache.clear()
super().delete(records)
@classmethod
def set_optional(cls, view_id, fields):
"Store optional field that must be displayed"
user = Transaction().user
records = cls.search([
('view_id', '=', view_id),
('user', '=', user),
])
cls.delete(records)
to_create = []
for field, value in fields.items():
to_create.append({
'view_id': view_id,
'user': user,
'field': field,
'value': bool(value),
})
if to_create:
cls.create(to_create)
class ViewTreeState(ModelSQL, ModelView):
'View Tree State'
__name__ = 'ir.ui.view_tree_state'
_rec_name = 'model'
model = fields.Char('Model', required=True)
domain = fields.Char('Domain', required=True)
user = fields.Many2One('res.user', 'User', required=True,
ondelete='CASCADE')
child_name = fields.Char('Child Name')
nodes = fields.Text('Expanded Nodes')
selected_nodes = fields.Text('Selected Nodes')
@classmethod
def __setup__(cls):
super(ViewTreeState, cls).__setup__()
cls.__rpc__.update({
'set': RPC(readonly=False, check_access=False),
'get': RPC(check_access=False, cache=dict(days=1)),
})
table = cls.__table__()
cls._sql_indexes.add(
Index(
table,
(table.user, Index.Equality()),
(table.model, Index.Equality()),
(table.child_name, Index.Equality()),
(table.domain, Index.Equality())))
@staticmethod
def default_nodes():
return '[]'
@staticmethod
def default_selected_nodes():
return '[]'
@classmethod
def set(cls, model, domain, child_name, nodes, selected_nodes):
# Normalize the json domain
logger.info("VIEWTREESTATE:%s",domain)
logger.info("VIEWTREESTATE2:%s",child_name)
logger.info("VIEWTREESTATE3:%s",nodes)
logger.info("VIEWTREESTATE4:%s",cls.__table__())
domain = json.dumps(json.loads(domain), separators=(',', ':'))
current_user = Transaction().user
records = cls.search([
('user', '=', current_user),
('model', '=', model),
('domain', '=', domain),
('child_name', '=', child_name),
])
cls.delete(records)
cls.create([{
'user': current_user,
'model': model,
'domain': domain,
'child_name': child_name,
'nodes': nodes,
'selected_nodes': selected_nodes,
}])
@classmethod
def get(cls, model, domain, child_name):
# Normalize the json domain
domain = json.dumps(json.loads(domain), separators=(',', ':'))
current_user = Transaction().user
try:
expanded_info, = cls.search([
('user', '=', current_user),
('model', '=', model),
('domain', '=', domain),
('child_name', '=', child_name),
],
limit=1)
except ValueError:
return (cls.default_nodes(), cls.default_selected_nodes())
state = cls(expanded_info)
return (state.nodes or cls.default_nodes(),
state.selected_nodes or cls.default_selected_nodes())
class ViewSearch(ModelSQL, ModelView):
"View Search"
__name__ = 'ir.ui.view_search'
name = fields.Char('Name', required=True)
model = fields.Char('Model', required=True)
domain = fields.Char('Domain', help="The PYSON domain.")
user = fields.Many2One('res.user', 'User', ondelete='CASCADE')
@classmethod
def __setup__(cls):
super(ViewSearch, cls).__setup__()
cls.__rpc__.update({
'get': RPC(check_access=False),
'set': RPC(check_access=False, readonly=False),
'unset': RPC(check_access=False, readonly=False),
})
@classmethod
def __register__(cls, module):
super().__register__(module)
table_h = cls.__table_handler__(module)
# Migration from 5.6: remove user required
table_h.not_null_action('user', 'remove')
@staticmethod
def default_user():
return Transaction().user
@classmethod
def validate_fields(cls, searches, field_names):
super().validate_fields(searches, field_names)
cls.check_domain(searches, field_names)
@classmethod
def check_domain(cls, searches, field_names):
decoder = PYSONDecoder()
if field_names and 'domain' not in field_names:
return
for search in searches:
try:
value = decoder.decode(search.domain)
except Exception as exception:
raise DomainError(
gettext('ir.msg_view_search_invalid_domain',
domain=search.domain,
search=search.rec_name)) from exception
if isinstance(value, PYSON):
if not value.types() == set([list]):
raise DomainError(
gettext('ir.msg_view_search_invalid_domain',
domain=search.domain,
search=search.rec_name))
elif not isinstance(value, list):
raise DomainError(
gettext('ir.msg_view_search_invalid_domain',
domain=search.domain,
search=search.rec_name))
else:
try:
fields.domain_validate(value)
except Exception as exception:
raise DomainError(
gettext('ir.msg_view_search_invalid_domain',
domain=search.domain,
search=search.rec_name)) from exception
@classmethod
def get(cls):
decoder = PYSONDecoder()
user = Transaction().user
searches = cls.search_read(['OR',
('user', '=', user),
('user', '=', None),
],
order=[('model', 'ASC'), ('name', 'ASC')],
fields_names=['id', 'name', 'model', 'domain', '_delete'])
result = {}
for search in searches:
result.setdefault(search['model'], []).append((
search['id'],
search['name'],
decoder.decode(search['domain']),
search['_delete']))
return result
@classmethod
def set(cls, name, model, domain):
user = Transaction().user
search, = cls.create([{
'name': name,
'model': model,
'domain': domain,
'user': user,
}])
return search.id
@classmethod
def unset(cls, id):
user = Transaction().user
cls.delete(cls.search([
('id', '=', id),
('user', '=', user),
]))