changeset d1109e268342 in modules/party_relationship:default
details:
https://hg.tryton.org/modules/party_relationship?cmd=changeset;node=d1109e268342
description:
Add proximity sorting of parties
issue5853
review28731002
diffstat:
CHANGELOG | 2 +
__init__.py | 1 +
doc/index.rst | 16 ++++
party.py | 128 ++++++++++++++++++++++++++++++++++++++-
tests/test_party_relationship.py | 113 ++++++++++++++++++++++++++++++++++
view/relation_type_form.xml | 2 +
6 files changed, 261 insertions(+), 1 deletions(-)
diffs (335 lines):
diff -r 9ada0105c9ae -r d1109e268342 CHANGELOG
--- a/CHANGELOG Sun Mar 01 16:12:39 2020 +0100
+++ b/CHANGELOG Sat Mar 07 00:27:13 2020 +0100
@@ -1,3 +1,5 @@
+* Add proximity sorting of parties
+
Version 5.4.0 - 2019-11-04
* Bug fixes (see mercurial logs for details)
diff -r 9ada0105c9ae -r d1109e268342 __init__.py
--- a/__init__.py Sun Mar 01 16:12:39 2020 +0100
+++ b/__init__.py Sat Mar 07 00:27:13 2020 +0100
@@ -10,4 +10,5 @@
party.Relation,
party.RelationAll,
party.Party,
+ party.ContactMechanism,
module='party_relationship', type_='model')
diff -r 9ada0105c9ae -r d1109e268342 doc/index.rst
--- a/doc/index.rst Sun Mar 01 16:12:39 2020 +0100
+++ b/doc/index.rst Sat Mar 07 00:27:13 2020 +0100
@@ -7,3 +7,19 @@
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 -r 9ada0105c9ae -r d1109e268342 party.py
--- a/party.py Sun Mar 01 16:12:39 2020 +0100
+++ b/party.py Sat Mar 07 00:27:13 2020 +0100
@@ -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 @@
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 @@
__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 -r 9ada0105c9ae -r d1109e268342 tests/test_party_relationship.py
--- a/tests/test_party_relationship.py Sun Mar 01 16:12:39 2020 +0100
+++ b/tests/test_party_relationship.py Sat Mar 07 00:27:13 2020 +0100
@@ -4,6 +4,7 @@
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 @@
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 -r 9ada0105c9ae -r d1109e268342 view/relation_type_form.xml
--- a/view/relation_type_form.xml Sun Mar 01 16:12:39 2020 +0100
+++ b/view/relation_type_form.xml Sat Mar 07 00:27:13 2020 +0100
@@ -6,5 +6,7 @@
<field name="name"/>
<label name="reverse"/>
<field name="reverse"/>
+ <separator name="usages" colspan="4"/>
+ <field name="usages" colspan="4"/>
</form>