diff --git a/CHANGELOG b/CHANGELOG index 4430aab7ce..44a7f96f9a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add proximity sorting of parties + Version 5.4.0 - 2019-11-04 * Bug fixes (see mercurial logs for details) diff --git a/__init__.py b/__init__.py index 5f6ff50561..7925748b36 100644 --- a/__init__.py +++ b/__init__.py @@ -10,4 +10,5 @@ def register(): party.Relation, party.RelationAll, party.Party, + party.ContactMechanism, module='party_relationship', type_='model') diff --git a/doc/index.rst b/doc/index.rst index a79d071991..a12794b586 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -7,3 +7,19 @@ between parties. Each relation is defined by a relation type. A reverse relation type can be defined, so when creating a relation of a type, the reverse relation will be automatically created. + +It is possible to order parties by how closely related they are to a defined +party. The distance is calculated based on the number of steps it takes to get +from the defined party to another. By default all the different types of +relationship are considered, but this can be limited by adding +``relation_usages`` to the context. + +Configuration +************* + +The party_relationship module use the section `party_relationship` to retrieve +some parameters: + +- `depth`: The maximum number of steps to consider when calculating the + distance between parties. + The default value is `7`. diff --git a/party.py b/party.py index 96ad0b2ea3..896c8b2a24 100644 --- a/party.py +++ b/party.py @@ -1,11 +1,19 @@ # 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 sql import Union, As, Column, Null +import json +from functools import partial +from sql import Union, As, Column, Null, Literal, With, Select +from sql.aggregate import Min + +from trytond.config import config from trytond.pool import Pool, PoolMeta from trytond.model import ModelSQL, ModelView, fields from trytond.transaction import Transaction +dumps = partial(json.dumps, separators=(',', ':'), sort_keys=True) +default_depth = config.getint('party_relationship', 'depth', default=7) + class RelationType(ModelSQL, ModelView): 'Relation Type' @@ -15,6 +23,18 @@ class RelationType(ModelSQL, ModelView): help="The main identifier of the relation type.") reverse = fields.Many2One('party.relation.type', 'Reverse Relation', help="Create automatically the reverse relation.") + usages = fields.MultiSelection([], "Usages") + + @classmethod + def view_attributes(cls): + attributes = super().view_attributes() + if not cls.usages.selection: + attributes.extend([ + ('//separator[@name="usages"]', + 'states', {'invisible': True}), + ('//field[@name="usages"]', 'invisible', 1), + ]) + return attributes class Relation(ModelSQL): @@ -205,3 +225,109 @@ class Party(metaclass=PoolMeta): __name__ = 'party.party' relations = fields.One2Many('party.relation.all', 'from_', 'Relations') + distance = fields.Function(fields.Integer('Distance'), 'get_distance') + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order.insert(0, ('distance', 'ASC NULLS LAST')) + + @classmethod + def _distance_query(cls, usages=None, party=None, depth=None): + pool = Pool() + RelationAll = pool.get('party.relation.all') + RelationType = pool.get('party.relation.type') + + transaction = Transaction() + context = transaction.context + database = transaction.database + + if usages is None: + usages = context.get('relation_usages', []) + if party is None: + party = context.get('related_party') + if depth is None: + depth = context.get('depth', default_depth) + + if not party: + return + + all_relations = RelationAll.__table__() + + if usages: + relation_type = RelationType.__table__() + try: + usages_clause = database.json_any_keys_exist( + relation_type.usages, list(usages)) + except NotImplementedError: + usages_clause = Literal(False) + for usage in usages: + usages_clause |= relation_type.usages.like( + '%' + dumps(usage) + '%') + relations = (all_relations + .join(relation_type, + condition=all_relations.type == relation_type.id) + .select( + all_relations.from_, all_relations.to, + where=usages_clause)) + else: + relations = all_relations + + distance = With('from_', 'to', 'distance', recursive=True) + distance.query = relations.select( + Column(relations, 'from_'), + relations.to, + Literal(1).as_('distance'), + where=Column(relations, 'from_') == party) + distance.query |= (distance + .join(relations, + condition=distance.to == Column(relations, 'from_')) + .select( + distance.from_, + relations.to, + (distance.distance + Literal(1)).as_('distance'), + where=(relations.to != party) + & (distance.distance < depth))) + distance.query.all_ = True + + return (distance + .select( + distance.to, Min(distance.distance).as_('distance'), + group_by=[distance.to], with_=[distance]) + | Select([Literal(party).as_('to'), Literal(0).as_('distance')])) + + @classmethod + def order_distance(cls, tables): + party, _ = tables[None] + key = 'distance' + if key not in tables: + query = cls._distance_query() + if not query: + return [] + join = party.join(query, type_='LEFT', + condition=query.to == party.id) + tables[key] = { + None: (join.right, join.condition), + } + else: + query, _ = tables[key][None] + return [query.distance] + + @classmethod + def get_distance(cls, parties, name): + distances = {p.id: None for p in parties} + query = cls._distance_query() + if query: + cursor = Transaction().connection.cursor() + cursor.execute(*query) + distances.update(cursor) + return distances + + +class ContactMechanism(metaclass=PoolMeta): + __name__ = 'party.contact_mechanism' + + @classmethod + def __setup__(cls): + super().__setup__() + cls._order.insert(0, ('party.distance', 'ASC NULLS LAST')) diff --git a/tests/test_party_relationship.py b/tests/test_party_relationship.py index 1a30b840e0..e5b9bd6d4b 100644 --- a/tests/test_party_relationship.py +++ b/tests/test_party_relationship.py @@ -4,6 +4,7 @@ import unittest from trytond.tests.test_tryton import ModuleTestCase, with_transaction from trytond.tests.test_tryton import suite as tryton_suite from trytond.pool import Pool +from trytond.transaction import Transaction class TestCase(ModuleTestCase): @@ -173,6 +174,118 @@ class TestCase(ModuleTestCase): RelationAll.delete([reverse_relation]) self.assertEqual(RelationAll.search([]), []) + @with_transaction() + def test_party_distance(self): + "Test party distance" + pool = Pool() + Party = pool.get('party.party') + RelationType = pool.get('party.relation.type') + Relation = pool.get('party.relation') + + usages = RelationType.usages.selection + self.addCleanup(setattr, RelationType.usages, 'selection', usages) + RelationType.usages.selection = usages + [('test', "Test")] + + relation, reverse = RelationType.create([{ + 'name': 'Relation', + }, { + 'name': 'Reverse', + }]) + relation.reverse = reverse + relation.save() + reverse.reverse = relation + reverse.save() + + A, B, C, D = Party.create([{ + 'name': 'A', + }, { + 'name': 'B', + }, { + 'name': 'C', + }, { + 'name': 'D' + }]) + Relation.create([{ + 'from_': A.id, + 'to': C.id, + 'type': relation.id, + }, { + 'from_': C.id, + 'to': D.id, + 'type': relation.id, + }]) + + parties = Party.search([]) + self.assertEqual([p.distance for p in parties], [None] * 4) + + with Transaction().set_context(related_party=A.id): + parties = Party.search([]) + self.assertEqual( + [(p.name, p.distance) for p in parties], + [('A', 0), ('C', 1), ('D', 2), ('B', None)]) + + another_relation, = RelationType.create([{ + 'name': 'Another Relation', + 'usages': ['test'], + }]) + Relation.create([{ + 'from_': A.id, + 'to': B.id, + 'type': another_relation.id, + }]) + + with Transaction().set_context( + related_party=A.id, relation_usages=['test']): + parties = Party.search([]) + self.assertEqual( + [(p.name, p.distance) for p in parties], + [('A', 0), ('B', 1), ('C', None), ('D', None)]) + + with Transaction().set_context(related_party=A.id): + parties = Party.search([]) + self.assertEqual( + [(p.name, p.distance) for p in parties], + [('A', 0), ('B', 1), ('C', 1), ('D', 2)]) + + @with_transaction() + def test_contact_mechanism_distance(self): + "Test relation distance" + pool = Pool() + Party = pool.get('party.party') + ContactMechanism = pool.get('party.contact_mechanism') + RelationType = pool.get('party.relation.type') + Relation = pool.get('party.relation') + + relation, = RelationType.create([{ + 'name': 'Relation', + }]) + + A, B, C = Party.create([{ + 'name': "A", + 'contact_mechanisms': [ + ('create', [{'value': "A", 'type': 'other'}])], + }, { + 'name': "B", + 'contact_mechanisms': [ + ('create', [{'value': "B", 'type': 'other'}])], + }, { + 'name': "C", + 'contact_mechanisms': [ + ('create', [{'value': "C", 'type': 'other'}])], + }]) + + Relation.create([{ + 'from_': A.id, + 'to': C.id, + 'type': relation.id, + }]) + + with Transaction().set_context(related_party=A.id): + contact_mechanisms = ContactMechanism.search([]) + self.assertEqual( + [c.value for c in contact_mechanisms], + ['A', 'C', 'B']) + def suite(): suite = tryton_suite() diff --git a/view/relation_type_form.xml b/view/relation_type_form.xml index f52b97b98f..0867e1bf29 100644 --- a/view/relation_type_form.xml +++ b/view/relation_type_form.xml @@ -6,5 +6,7 @@ this repository contains the full copyright notices and license terms. -->