Add tree mixin
- Implement generic get_rec_name and search_rec_name - Include recursive check issue7383 review48281002
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add tree mixin
|
||||
* Rename "install-dependences" into "activate-dependencies"
|
||||
* Use recursive common table expression for child_of/parent_of operators
|
||||
* Add sql_cast on Field
|
||||
|
||||
@@ -334,11 +334,6 @@ Class methods:
|
||||
modification of data coming from XML files. This method must be overiden to
|
||||
change this behavior.
|
||||
|
||||
.. classmethod:: ModelStorage.check_recursion(records[, parent])
|
||||
|
||||
Helper method that checks if there is no recursion in the tree composed
|
||||
with ``parent`` as parent field name.
|
||||
|
||||
.. classmethod:: ModelStorage.validate(records)
|
||||
|
||||
Validate the integrity of records after creation and modification. This
|
||||
@@ -818,3 +813,25 @@ Class attributes are:
|
||||
.. _mixin: http://en.wikipedia.org/wiki/Mixin
|
||||
.. _JSON: http://en.wikipedia.org/wiki/Json
|
||||
.. _UNION: http://en.wikipedia.org/wiki/Union_(SQL)#UNION_operator
|
||||
|
||||
====
|
||||
tree
|
||||
====
|
||||
|
||||
.. method:: tree([parent[, name[, separator]]])
|
||||
|
||||
Returns a mixin_ class :class:`TreeMixin`. `parent` indicates the name of the
|
||||
field that defines the parent of the tree and its default value is `parent`.
|
||||
`name` indicates the name of the field that defines the name of the record and
|
||||
its default value is `name`. If `separator` is set, the
|
||||
:meth:`ModelStorage.get_rec_name` constructs the name by concatenating each
|
||||
parent names using it as separator and :meth:`ModelStorage.search_rec_name` is
|
||||
adapted to search across the tree.
|
||||
|
||||
|
||||
.. class:: TreeMixin
|
||||
|
||||
.. classmethod:: TreeMixin.check_recursion(records)
|
||||
|
||||
Helper method that checks if there is no recursion in the tree defined by
|
||||
:meth:`tree`.
|
||||
|
||||
+3
-45
@@ -3,7 +3,7 @@
|
||||
from itertools import groupby
|
||||
|
||||
from trytond.model import (
|
||||
ModelView, ModelSQL, DeactivableMixin, fields, sequence_ordered)
|
||||
ModelView, ModelSQL, DeactivableMixin, fields, sequence_ordered, tree)
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.tools import grouped_slice
|
||||
from trytond.pool import Pool
|
||||
@@ -68,10 +68,10 @@ CLIENT_ICONS = [(x, x) for x in (
|
||||
'tryton-system',
|
||||
'tryton-undo',
|
||||
'tryton-web-browser')]
|
||||
SEPARATOR = ' / '
|
||||
|
||||
|
||||
class UIMenu(DeactivableMixin, sequence_ordered(), ModelSQL, ModelView):
|
||||
class UIMenu(DeactivableMixin, sequence_ordered(), tree(separator=' / '),
|
||||
ModelSQL, ModelView):
|
||||
"UI menu"
|
||||
__name__ = 'ir.ui.menu'
|
||||
|
||||
@@ -96,14 +96,6 @@ class UIMenu(DeactivableMixin, sequence_ordered(), ModelSQL, ModelView):
|
||||
'Action Keywords')
|
||||
favorite = fields.Function(fields.Boolean('Favorite'), 'get_favorite')
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super(UIMenu, cls).__setup__()
|
||||
cls._error_messages.update({
|
||||
'wrong_name': ('"%%s" is not a valid menu name because it is '
|
||||
'not allowed to contain "%s".' % SEPARATOR),
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def order_complete_name(cls, tables):
|
||||
return cls.name.convert_order('name', tables, cls)
|
||||
@@ -123,40 +115,6 @@ class UIMenu(DeactivableMixin, sequence_ordered(), ModelSQL, ModelView):
|
||||
return sorted(CLIENT_ICONS
|
||||
+ [(name, name) for _, name in Icon.list_icons()])
|
||||
|
||||
@classmethod
|
||||
def validate(cls, menus):
|
||||
super(UIMenu, cls).validate(menus)
|
||||
cls.check_recursion(menus)
|
||||
for menu in menus:
|
||||
menu.check_name()
|
||||
|
||||
def check_name(self):
|
||||
if SEPARATOR in self.name:
|
||||
self.raise_user_error('wrong_name', (self.name,))
|
||||
|
||||
def get_rec_name(self, name):
|
||||
parent = self.parent
|
||||
name = self.name
|
||||
while parent:
|
||||
name = parent.name + SEPARATOR + name
|
||||
parent = parent.parent
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def search_rec_name(cls, name, clause):
|
||||
if isinstance(clause[2], basestring):
|
||||
values = clause[2].split(SEPARATOR.strip())
|
||||
values.reverse()
|
||||
domain = []
|
||||
field = 'name'
|
||||
for name in values:
|
||||
domain.append((field, clause[1], name.strip()))
|
||||
field = 'parent.' + field
|
||||
else:
|
||||
domain = [('name',) + tuple(clause[1:])]
|
||||
ids = [m.id for m in cls.search(domain, order=[])]
|
||||
return [('parent', 'child_of', ids)]
|
||||
|
||||
@classmethod
|
||||
def search_global(cls, text):
|
||||
# TODO improve search clause
|
||||
|
||||
@@ -13,9 +13,10 @@ from .multivalue import MultiValueMixin, ValueMixin
|
||||
from .descriptors import dualmethod
|
||||
from .order import sequence_ordered
|
||||
from .active import DeactivableMixin
|
||||
from .tree import tree
|
||||
|
||||
__all__ = ['Model', 'ModelView', 'ModelStorage', 'ModelSingleton', 'ModelSQL',
|
||||
'Check', 'Unique', 'Exclude',
|
||||
'Workflow', 'DictSchemaMixin', 'MatchMixin', 'UnionMixin', 'dualmethod',
|
||||
'MultiValueMixin', 'ValueMixin',
|
||||
'EvalEnvironment', 'sequence_ordered', 'DeactivableMixin']
|
||||
'EvalEnvironment', 'sequence_ordered', 'DeactivableMixin', 'tree']
|
||||
|
||||
@@ -818,49 +818,6 @@ class ModelStorage(Model):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def check_recursion(cls, records, parent='parent', rec_name='rec_name'):
|
||||
'''
|
||||
Function that checks if there is no recursion in the tree
|
||||
composed with parent as parent field name.
|
||||
'''
|
||||
parent_type = cls._fields[parent]._type
|
||||
|
||||
if parent_type not in ('many2one', 'many2many', 'one2one'):
|
||||
raise Exception(
|
||||
'Unsupported field type "%s" for field "%s" on "%s"'
|
||||
% (parent_type, parent, cls.__name__))
|
||||
|
||||
visited = set()
|
||||
|
||||
for record in records:
|
||||
walked = set()
|
||||
walker = getattr(record, parent)
|
||||
while walker:
|
||||
if parent_type == 'many2many':
|
||||
for walk in walker:
|
||||
walked.add(walk.id)
|
||||
if walk.id == record.id:
|
||||
parent_rec_name = ', '.join(getattr(r, rec_name)
|
||||
for r in getattr(record, parent))
|
||||
cls.raise_user_error('recursion_error', {
|
||||
'rec_name': getattr(record, rec_name),
|
||||
'parent_rec_name': parent_rec_name,
|
||||
})
|
||||
walker = list(chain(*(getattr(walk, parent)
|
||||
for walk in walker if walk.id not in visited)))
|
||||
else:
|
||||
walked.add(walker.id)
|
||||
if walker.id == record.id:
|
||||
cls.raise_user_error('recursion_error', {
|
||||
'rec_name': getattr(record, rec_name),
|
||||
'parent_rec_name': getattr(getattr(record,
|
||||
parent), rec_name)
|
||||
})
|
||||
walker = (getattr(walker, parent) not in visited
|
||||
and getattr(walker, parent))
|
||||
visited.update(walked)
|
||||
|
||||
@classmethod
|
||||
def _get_error_args(cls, field_name):
|
||||
pool = Pool()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
from itertools import chain
|
||||
|
||||
|
||||
def tree(parent='parent', name='name', separator=None):
|
||||
class TreeMixin(object):
|
||||
|
||||
if separator:
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super(TreeMixin, cls).__setup__()
|
||||
field = getattr(cls, name)
|
||||
field.domain = [
|
||||
field.domain,
|
||||
(name, 'not like', '%' + separator + '%'),
|
||||
]
|
||||
|
||||
def get_rec_name(self, _):
|
||||
record, names = self, []
|
||||
while record:
|
||||
names.append(getattr(record, name))
|
||||
record = getattr(record, parent)
|
||||
return separator.join(reversed(names))
|
||||
|
||||
@classmethod
|
||||
def search_rec_name(cls, _, clause):
|
||||
if isinstance(clause[2], basestring):
|
||||
values = list(reversed(clause[2].split(separator)))
|
||||
else:
|
||||
values = [[]]
|
||||
for value in clause[2]:
|
||||
if value is None:
|
||||
values[0].append(value)
|
||||
continue
|
||||
for i, v in range(reversed(value.split(separator))):
|
||||
while len(values) <= i:
|
||||
values.append([])
|
||||
values[i].append(v)
|
||||
domain = []
|
||||
field = name
|
||||
for value in values:
|
||||
domain.append((field, clause[1], value.strip()))
|
||||
field = parent + '.' + field
|
||||
if ((clause[1].endswith('like')
|
||||
and not clause[2].replace(
|
||||
'%%', '__').startswith('%'))
|
||||
or not clause[1].endswith('like')):
|
||||
if clause[1].startswith('not') or clause[1] == '!=':
|
||||
operator = '!='
|
||||
domain.insert(0, 'OR')
|
||||
else:
|
||||
operator = '='
|
||||
top_parent = '.'.join((parent,) * len(values))
|
||||
domain.append((top_parent, operator, None))
|
||||
if (clause[1].endswith('like')
|
||||
and clause[2].replace('%%', '__').endswith('%')):
|
||||
ids = map(int, cls.search(domain, order=[]))
|
||||
domain = [(parent, 'child_of', ids)]
|
||||
return domain
|
||||
|
||||
@classmethod
|
||||
def validate(cls, records):
|
||||
super(TreeMixin, cls).validate(records)
|
||||
cls.check_recursion(records)
|
||||
|
||||
@classmethod
|
||||
def check_recursion(cls, records):
|
||||
'''
|
||||
Function that checks if there is no recursion in the tree
|
||||
composed with parent as parent field name.
|
||||
'''
|
||||
if hasattr(super(TreeMixin, cls), 'check_recursion'):
|
||||
super(TreeMixin, cls).check_recursion(records)
|
||||
|
||||
parent_type = cls._fields[parent]._type
|
||||
|
||||
if parent_type not in ('many2one', 'many2many', 'one2one'):
|
||||
raise ValueError(
|
||||
'Unsupported field type "%s" for field "%s" on "%s"'
|
||||
% (parent_type, parent, cls.__name__))
|
||||
|
||||
visited = set()
|
||||
|
||||
for record in records:
|
||||
walked = set()
|
||||
walker = getattr(record, parent)
|
||||
while walker:
|
||||
if parent_type == 'many2many':
|
||||
for walk in walker:
|
||||
walked.add(walk.id)
|
||||
if walk.id == record.id:
|
||||
parent_name = ', '.join(getattr(r, name)
|
||||
for r in getattr(record, parent))
|
||||
cls.raise_user_error('recursion_error', {
|
||||
'rec_name': getattr(record, name),
|
||||
'parent_rec_name': parent_name,
|
||||
})
|
||||
walker = list(chain(*(
|
||||
getattr(walk, parent)
|
||||
for walk in walker
|
||||
if walk.id not in visited)))
|
||||
else:
|
||||
walked.add(walker.id)
|
||||
if walker.id == record.id:
|
||||
parent_name = getattr(
|
||||
getattr(record, parent), name)
|
||||
cls.raise_user_error('recursion_error', {
|
||||
'rec_name': getattr(record, name),
|
||||
'parent_rec_name': parent_name
|
||||
})
|
||||
walker = (getattr(walker, parent) not in visited
|
||||
and getattr(walker, parent))
|
||||
visited.update(walked)
|
||||
|
||||
return TreeMixin
|
||||
@@ -42,6 +42,7 @@ from . import field_many2many
|
||||
from . import field_one2one
|
||||
from . import multivalue
|
||||
from . import mixin
|
||||
from . import tree
|
||||
|
||||
|
||||
def register():
|
||||
@@ -124,6 +125,7 @@ def register():
|
||||
field_one2one.register('tests')
|
||||
multivalue.register('tests')
|
||||
mixin.register('tests')
|
||||
tree.register('tests')
|
||||
|
||||
if pkg_resources is not None:
|
||||
entry_points = pkg_resources.iter_entry_points('trytond.tests')
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
from sql import Null
|
||||
from sql.conditionals import Case
|
||||
|
||||
from trytond.model import ModelView, ModelSQL, DeactivableMixin, fields
|
||||
from trytond.model import ModelView, ModelSQL, DeactivableMixin, tree, fields
|
||||
|
||||
__all__ = [
|
||||
'MPTT',
|
||||
]
|
||||
|
||||
|
||||
class MPTT(DeactivableMixin, ModelSQL, ModelView):
|
||||
class MPTT(DeactivableMixin, tree(), ModelSQL, ModelView):
|
||||
'Modified Preorder Tree Traversal'
|
||||
__name__ = 'test.mptt'
|
||||
name = fields.Char('Name', required=True)
|
||||
@@ -21,11 +21,6 @@ class MPTT(DeactivableMixin, ModelSQL, ModelView):
|
||||
right = fields.Integer('Right', required=True, select=True)
|
||||
childs = fields.One2Many('test.mptt', 'parent', 'Children')
|
||||
|
||||
@classmethod
|
||||
def validate(cls, record):
|
||||
super(MPTT, cls).validate(record)
|
||||
cls.check_recursion(record)
|
||||
|
||||
@staticmethod
|
||||
def order_sequence(tables):
|
||||
table, _ = tables[None]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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 unittest
|
||||
|
||||
from trytond.exceptions import UserError
|
||||
from trytond.tests.test_tryton import activate_module, with_transaction
|
||||
from trytond.pool import Pool
|
||||
|
||||
|
||||
class TreeMixinTestCase(unittest.TestCase):
|
||||
"Test TreeMixin"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
activate_module('tests')
|
||||
|
||||
@with_transaction()
|
||||
def test_name_domain(self):
|
||||
"Test name domain"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
record = Tree(name="foo / bar")
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
record.save()
|
||||
|
||||
@with_transaction()
|
||||
def test_rec_name(self):
|
||||
"Test rec_name"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
|
||||
self.assertEqual(record.rec_name, "parent / record")
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_equals(self):
|
||||
"Test search_rec_name equals"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
|
||||
records = Tree.search([('rec_name', '=', 'parent / record')])
|
||||
|
||||
self.assertEqual(records, [record])
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_equals_toplevel(self):
|
||||
"Test search_rec_name equals top-level"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
|
||||
records = Tree.search([('rec_name', '=', 'parent')])
|
||||
|
||||
self.assertEqual(records, [parent])
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_non_equals(self):
|
||||
"Test search_rec_name non equals"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
|
||||
records = Tree.search([('rec_name', '!=', 'parent / record')])
|
||||
|
||||
self.assertEqual(records, [parent])
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_non_equals_toplevel(self):
|
||||
"Test search_rec_name non equals top-level"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
|
||||
records = Tree.search([('rec_name', '!=', 'parent')])
|
||||
|
||||
self.assertEqual(records, [record])
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_like(self):
|
||||
"Test search_rec_name like"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
child = Tree(name="child", parent=record)
|
||||
child.save()
|
||||
|
||||
records = Tree.search([('rec_name', 'like', '%record%')])
|
||||
|
||||
self.assertEqual(records, [record, child])
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_like_toplevel(self):
|
||||
"Test search_rec_name like top-level"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
child = Tree(name="child", parent=record)
|
||||
child.save()
|
||||
|
||||
records = Tree.search([('rec_name', 'like', 'parent / record%')])
|
||||
|
||||
self.assertEqual(records, [record, child])
|
||||
|
||||
@with_transaction()
|
||||
def test_search_rec_name_like_lowlevel(self):
|
||||
"Test search_rec_name like low-level"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
child = Tree(name="child", parent=record)
|
||||
child.save()
|
||||
|
||||
records = Tree.search([('rec_name', 'like', '%record')])
|
||||
|
||||
self.assertEqual(records, [record])
|
||||
|
||||
@with_transaction()
|
||||
def test_check_recursion(self):
|
||||
"Test check_recursion"
|
||||
pool = Pool()
|
||||
Tree = pool.get('test.tree')
|
||||
|
||||
parent = Tree(name="parent")
|
||||
parent.save()
|
||||
record = Tree(name="record", parent=parent)
|
||||
record.save()
|
||||
child = Tree(name="child", parent=record)
|
||||
child.save()
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
parent.parent = child
|
||||
parent.save()
|
||||
|
||||
@with_transaction()
|
||||
def test_check_recursion_polytree(self):
|
||||
"Test check_recursion on polytree"
|
||||
pool = Pool()
|
||||
Polytree = pool.get('test.polytree')
|
||||
|
||||
parent1 = Polytree(name="parent1")
|
||||
parent1.save()
|
||||
parent2 = Polytree(name="parent2")
|
||||
parent2.save()
|
||||
record = Polytree(name="record", parents=[parent1, parent2])
|
||||
record.save()
|
||||
child = Polytree(name="child", parents=[record])
|
||||
child.save()
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
parent1.parents = [child]
|
||||
parent1.save()
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromTestCase(TreeMixinTestCase)
|
||||
@@ -0,0 +1,35 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
from trytond.model import ModelSQL, tree, fields
|
||||
from trytond.pool import Pool
|
||||
|
||||
|
||||
class Tree(tree(separator=' / '), ModelSQL):
|
||||
"Tree"
|
||||
__name__ = 'test.tree'
|
||||
name = fields.Char("Name")
|
||||
parent = fields.Many2One('test.tree', "Parent")
|
||||
|
||||
|
||||
class Polytree(tree(parent='parents'), ModelSQL):
|
||||
"PolyTree"
|
||||
__name__ = 'test.polytree'
|
||||
name = fields.Char("Name")
|
||||
parents = fields.Many2Many(
|
||||
'test.polytree.edge', 'parent', 'child', "Parents")
|
||||
|
||||
|
||||
class PolytreeEdge(ModelSQL):
|
||||
"Polytree Edge"
|
||||
__name__ = 'test.polytree.edge'
|
||||
parent = fields.Many2One('test.polytree', "Parent")
|
||||
child = fields.Many2One('test.polytree', "Child")
|
||||
|
||||
|
||||
def register(module):
|
||||
Pool.register(
|
||||
Tree,
|
||||
Polytree,
|
||||
PolytreeEdge,
|
||||
module=module, type_='model')
|
||||
Reference in New Issue
Block a user