From 9d867f62e877a7fb1850108342e191b51278dffe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Sun, 14 Nov 2021 19:30:43 +0100
Subject: [PATCH] formdefs: store reverse relations on form/carddefs (#57963)

---
 tests/test_carddef.py | 213 +++++++++++++++++++++++++++++++++++++++++-
 wcs/blocks.py         |  14 +++
 wcs/formdef.py        |  85 +++++++++++++++++
 3 files changed, 311 insertions(+), 1 deletion(-)

diff --git a/tests/test_carddef.py b/tests/test_carddef.py
index 28c3caaa..71743cbd 100644
--- a/tests/test_carddef.py
+++ b/tests/test_carddef.py
@@ -3,9 +3,11 @@ import xml.etree.ElementTree as ET
 
 import pytest
 
+from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
 from wcs.categories import CardDefCategory
-from wcs.fields import ItemField, StringField
+from wcs.fields import BlockField, ItemField, ItemsField, StringField
+from wcs.formdef import FormDef
 from wcs.qommon.http_request import HTTPRequest
 from wcs.qommon.misc import indent_xml as indent
 from wcs.qommon.template import Template
@@ -583,3 +585,212 @@ def test_data_source_query_escape(pub):
     cards = CardDef.get_data_source_items('carddef:foo', query='_')
     assert len(cards) == 1
     assert cards[0]['text'] == 'Astreinte\\Mardi _'
+
+
+def test_reverse_relations(pub):
+    FormDef.wipe()
+    CardDef.wipe()
+    BlockDef.wipe()
+
+    formdef1 = FormDef()
+    formdef1.name = 'formdef 1'
+    formdef1.store()
+
+    formdef2 = FormDef()
+    formdef2.name = 'formdef 2'
+    formdef2.store()
+
+    carddef1 = CardDef()
+    carddef1.name = 'carddef 1'
+    carddef1.store()
+
+    carddef2 = CardDef()
+    carddef2.name = 'carddef 2'
+    carddef2.store()
+
+    block1 = BlockDef()
+    block1.name = 'block 1'
+    block1.fields = [
+        ItemField(
+            id='1',
+            label='item',
+            type='item',
+            varname='block_foo_1',
+            data_source={'type': 'carddef:carddef-1'},
+        ),
+        ItemsField(id='2', label='items', type='items', data_source={'type': 'carddef:carddef-1'}),
+    ]
+    block1.store()
+
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    assert carddef1.reverse_relations == []
+    assert carddef2.reverse_relations == []
+
+    formdef1.fields = [
+        ItemField(id='1', label='item', type='item', data_source={'type': 'carddef:carddef-1'}),
+    ]
+    formdef1.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == []
+
+    formdef2.fields = [
+        ItemsField(
+            id='1', label='items', type='items', varname='bar', data_source={'type': 'carddef:carddef-2'}
+        ),
+    ]
+    formdef2.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
+
+    carddef1.fields = [
+        ItemField(id='1', label='item', type='item', data_source={'type': 'carddef:carddef-2'}),
+    ]
+    carddef1.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'carddef:carddef-1'},
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
+
+    carddef1.fields = [
+        ItemsField(id='1', label='items', type='items', data_source={'type': 'carddef:carddef-2'}),
+    ]
+    carddef1.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-1'},
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
+
+    # circular relation ?
+    carddef2.fields = [
+        ItemsField(id='1', label='items', type='items', data_source={'type': 'carddef:carddef-2'}),
+    ]
+    carddef2.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-2'},
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
+
+    # block field
+    formdef1.fields.append(BlockField(id='2', label='block', type='block:%s' % block1.slug))
+    formdef1.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    # no varname for block field, item/formdef-1 is already in reverse_relations
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-2'},
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
+
+    formdef1.fields[1] = BlockField(id='2', label='block', type='block:%s' % block1.slug, varname='foo')
+    formdef1.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    # varname defined for block field
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'formdef:formdef-1'},
+        {'varname': 'foo_block_foo_1', 'type': 'item', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-2'},
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
+
+    # update blockdef fields
+    block1.fields = [
+        ItemField(
+            id='1',
+            label='item',
+            type='item',
+            varname='block_foo_1',
+            data_source={'type': 'carddef:carddef-2'},
+        ),
+        ItemsField(id='2', label='items', type='items', data_source={'type': 'carddef:carddef-1'}),
+    ]
+    block1.store()
+
+    formdef1.refresh_from_storage()
+    formdef2.refresh_from_storage()
+    carddef1.refresh_from_storage()
+    carddef2.refresh_from_storage()
+    assert formdef1.reverse_relations == []
+    assert formdef2.reverse_relations == []
+    # varname defined for block field
+    assert carddef1.reverse_relations == [
+        {'varname': '', 'type': 'item', 'obj': 'formdef:formdef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'formdef:formdef-1'},
+    ]
+    assert carddef2.reverse_relations == [
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-1'},
+        {'varname': '', 'type': 'items', 'obj': 'carddef:carddef-2'},
+        {'varname': 'foo_block_foo_1', 'type': 'item', 'obj': 'formdef:formdef-1'},
+        {'varname': 'bar', 'type': 'items', 'obj': 'formdef:formdef-2'},
+    ]
diff --git a/wcs/blocks.py b/wcs/blocks.py
index a1ec62ce..17f6daba 100644
--- a/wcs/blocks.py
+++ b/wcs/blocks.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
+import itertools
 import uuid
 import xml.etree.ElementTree as ET
 
@@ -52,6 +53,9 @@ class BlockDef(StorableObject):
         self.fields = []
 
     def store(self, comment=None, *args, **kwargs):
+        from wcs.carddef import CardDef
+        from wcs.formdef import FormDef
+
         assert not self.is_readonly()
         if self.slug is None:
             # set slug if it's not yet there
@@ -61,6 +65,16 @@ class BlockDef(StorableObject):
         if get_publisher().snapshot_class:
             get_publisher().snapshot_class.snap(instance=self, comment=comment)
 
+        # update relations
+        for objdef in itertools.chain(
+            FormDef.select(ignore_errors=True, ignore_migration=True),
+            CardDef.select(ignore_errors=True, ignore_migration=True),
+        ):
+            for field in objdef.get_all_fields():
+                if field.key == 'block' and field.type == 'block:%s' % self.slug:
+                    objdef.store()
+                    continue
+
     def get_new_slug(self):
         new_slug = misc.simplify(self.name, space='_')
         base_new_slug = new_slug
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 506a43d8..d2114d45 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -28,6 +28,7 @@ import sys
 import time
 import types
 import xml.etree.ElementTree as ET
+from operator import itemgetter
 
 from django.utils.encoding import force_bytes, force_text
 from quixote import get_publisher, get_session
@@ -145,6 +146,9 @@ class FormDef(StorableObject):
 
     max_field_id = None
 
+    # store reverse relations
+    reverse_relations = None
+
     # store fields in a separate pickle chunk
     lightweight = True
 
@@ -445,7 +449,12 @@ class FormDef(StorableObject):
             if self.id is None or get_publisher().is_using_postgresql() or self.data_class().count() == 0:
                 self.internal_identifier = new_internal_identifier
         object_only = kwargs.pop('object_only', False)
+
+        if not object_only:
+            self.update_relations()
+
         StorableObject.store(self, *args, **kwargs)
+
         if object_only:
             return
         if get_publisher().snapshot_class:
@@ -466,6 +475,82 @@ class FormDef(StorableObject):
             for action in actions:
                 getattr(cls, action)()
 
+    def update_relations(self):
+        from wcs.carddef import CardDef
+
+        self_ref = '%s:%s' % (self.xml_root_node, self.url_name)
+        self_relations_by_ref = self.build_relations_by_ref()
+        reverse_relations = []
+
+        # cross each formdef and cardef and check relations
+        for objdef in itertools.chain(
+            FormDef.select(ignore_errors=True, ignore_migration=True),
+            CardDef.select(ignore_errors=True, ignore_migration=True),
+        ):
+            objdef_ref = '%s:%s' % (objdef.xml_root_node, objdef.url_name)
+            if objdef.xml_root_node == self.xml_root_node and objdef.id == self.id:
+                # don't build relations twice
+                objdef_relations_by_ref = self_relations_by_ref
+            else:
+                objdef_relations_by_ref = objdef.build_relations_by_ref()
+            reverse_relations += objdef_relations_by_ref.get(self_ref, [])
+
+            old_objdef_reverse_relations = copy.deepcopy(objdef.reverse_relations)
+            # remove relations with self in objdef's reverse_relations
+            new_objdef_reverse_relations = [
+                r for r in (objdef.reverse_relations or []) if r['obj'] != self_ref
+            ]
+            # and update objdef's reverse_relations from self_relations_by_ref
+            new_objdef_reverse_relations += self_relations_by_ref.get(objdef_ref, [])
+            # sort objectdef's reverse_relations
+            new_objdef_reverse_relations = sorted(
+                new_objdef_reverse_relations, key=itemgetter('obj', 'varname', 'type')
+            )
+            if old_objdef_reverse_relations != new_objdef_reverse_relations:
+                objdef.reverse_relations = new_objdef_reverse_relations
+                objdef.store(object_only=True)
+        # sort self's reverse_relations and set
+        self.reverse_relations = sorted(reverse_relations, key=itemgetter('obj', 'varname', 'type'))
+
+    def build_relations_by_ref(self):
+        # build relations to other carddefs, to be stored in some object reverse field
+        self_ref = '%s:%s' % (self.xml_root_node, self.url_name)
+        relations_by_ref = collections.defaultdict(list)
+
+        def _check_field(field, in_block_field=False, prefix=''):
+            data_source = getattr(field, 'data_source', None)
+            if not data_source or not data_source['type'].startswith('carddef:'):
+                return
+            varname = field.varname or ''
+            if in_block_field:
+                if prefix and field.varname:
+                    varname = '%s_%s' % (prefix, field.varname)
+                else:
+                    varname = ''
+            # reverse relation of data_source['type'] to this object
+            relations_by_ref[data_source['type']].append(
+                {
+                    'varname': varname,
+                    'type': field.key,
+                    'obj': self_ref,
+                }
+            )
+
+        for field in self.get_all_fields():
+            if field.key in ['item', 'items']:
+                _check_field(field)
+            if field.key == 'block':
+                for _field in field.block.fields:
+                    if _field.key not in ['item', 'items']:
+                        continue
+                    _check_field(_field, in_block_field=True, prefix=field.varname or '')
+                field._block = None  # reset cache
+
+        # remove duplicated items
+        return {
+            k: list(map(dict, {tuple(sorted(d.items())) for d in v})) for k, v in relations_by_ref.items()
+        }
+
     def store_related_custom_views(self):
         for view in getattr(self, '_custom_views', []):
             view.formdef = self
-- 
2.33.0

