From e93cd547a003b4cd03323556fadcc27fe80ab2b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 7 Dec 2021 10:16:51 +0100
Subject: [PATCH 4/5] wcs: get card ids from related (#58833)

---
 combo/apps/wcs/models.py | 141 +++++++++++-
 tests/test_wcs.py        | 452 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 589 insertions(+), 4 deletions(-)

diff --git a/combo/apps/wcs/models.py b/combo/apps/wcs/models.py
index 9f2a9182..9628d406 100644
--- a/combo/apps/wcs/models.py
+++ b/combo/apps/wcs/models.py
@@ -973,7 +973,7 @@ class WcsCardInfosCell(CardMixin, CellBase):
             context[self.global_context_key] = card_ids
 
     def get_repeat_template(self, context):
-        return len(context[self.global_context_key])
+        return len(context.get(self.global_context_key) or [])
 
     def get_card_data(self, card_slug, card_id, context, synchronous=False):
         api_url = '/api/cards/%s/%s/?include-files-content=off' % (card_slug, card_id)
@@ -1063,6 +1063,128 @@ class WcsCardInfosCell(CardMixin, CellBase):
             )
         return results
 
+    def get_card_ids_from_related(self, context, request):
+        def get_relation(relations, varname, reverse):
+            for relation in relations:
+                if relation['reverse'] == reverse and relation['varname'] == varname:
+                    return relation
+
+        def follow_data(card_data, relations, varname, parts):
+            reverse = varname.startswith('reverse:')
+            if reverse:
+                varname = varname[8:]  # remove 'reverse:'
+            relation = get_relation(relations, varname, reverse)
+            if not relation:
+                # not found - stop
+                return []
+            card_slug = relation['obj'][8:]  # remove 'carddef:'
+
+            if not parts and card_slug != self.card_slug:
+                # last part, but wrong card model
+                return []
+
+            if reverse:
+                # reverse relation: always multiple
+                if not parts:
+                    # last part - get ids and stop
+                    ids = self.get_card_ids_from_template(
+                        '{{ cards|objects:"%s"|filter_by:"%s"|filter_value:"%s"|getlist:"id"|join:"," }}'
+                        % (card_slug, varname, card_data['id']),
+                        context,
+                        request,
+                    )
+                    return ids
+                # multiple relation, but still parts to follow - stop
+                return []
+
+            # direct relation
+
+            if not parts:
+                # last part - stop
+                raw_value = None
+                if '%s_raw' % varname in card_data['fields']:
+                    raw_value = card_data['fields']['%s_raw' % varname]
+                else:
+                    # may be a fields block ?
+                    varname_parts = varname.split('_')
+                    for i in range(len(varname_parts)):
+                        block_varname = '%s_raw' % '_'.join(varname_parts[: i + 1])
+                        if block_varname in card_data['fields']:
+                            block_data = card_data['fields'][block_varname]
+                            if not block_data:
+                                continue
+                            field_varname = '%s_raw' % '_'.join(varname_parts[i + 1 :])
+                            values = [v.get(field_varname) for v in block_data]
+                            values = [v for v in values if v]
+                            if values:
+                                return values
+
+                if not raw_value:
+                    return []
+                if not isinstance(raw_value, list):
+                    # item or computed
+                    return [raw_value]
+                # items
+                return raw_value
+
+            if relation['type'] == 'items':
+                # multiple relation, but still parts to follow - stop
+                return []
+
+            # single relation, get card_data and follow next part
+
+            if not card_data['fields'].get('%s_raw' % varname):
+                # field not found or empty
+                return []
+
+            # get schema
+            card_schema = self.get_card_schema(card_slug)
+            if not card_schema:
+                # card schema not found
+                return []
+            # and data
+            next_card_data = self.get_card_data(
+                card_slug, card_data['fields']['%s_raw' % varname], context, synchronous=True
+            )
+            if not next_card_data:
+                # card data not found
+                return []
+            # continue
+            return follow_data(
+                card_data=next_card_data,
+                relations=card_schema['relations'],
+                varname=parts[0],
+                parts=parts[1:],
+            )
+
+        first_cell_slug = self.related_card_path.split('/')[0]
+        try:
+            first_cell = WcsCardInfosCell.objects.get(page=self.page_id, slug=first_cell_slug)
+        except (WcsCardInfosCell.DoesNotExist, WcsCardInfosCell.MultipleObjectsReturned):
+            return []
+        if first_cell.related_card_path:
+            # no explicit ids
+            return []
+        if ',' in first_cell.card_ids:
+            # multiple ids, can not follow relations
+            return []
+        first_cell.repeat_index = 0
+        card_id = first_cell.get_card_id(context)
+        if not card_id:
+            # no card id found
+            return []
+        card_data = self.get_card_data(first_cell.card_slug, card_id, context, synchronous=True)
+        if not card_data:
+            # card data not found
+            return []
+        parts = self.related_card_path.split('/')[1:]
+        return follow_data(
+            card_data=card_data,
+            relations=first_cell.cached_json['relations'],
+            varname=parts[0],
+            parts=parts[1:],
+        )
+
     def get_card_ids_from_template(self, template, original_context, request):
         try:
             context = RequestContext(request)
@@ -1073,6 +1195,14 @@ class WcsCardInfosCell(CardMixin, CellBase):
             return []
 
     def get_card_ids(self, original_context, request):
+        if not self.carddef_reference:
+            # not configured
+            return []
+
+        if self.related_card_path:
+            # look at other cells to get related ids
+            return self.get_card_ids_from_related(original_context, request)
+
         if self.card_ids:
             # card ids template is defined
             return self.get_card_ids_from_template(self.card_ids, original_context, request)
@@ -1088,8 +1218,10 @@ class WcsCardInfosCell(CardMixin, CellBase):
     def get_card_id(self, context):
         repeat_index = getattr(self, 'repeat_index', context.get('repeat_index'))
         if repeat_index is not None:
-            return context[self.global_context_key][repeat_index]
-        return None
+            try:
+                return context.get(self.global_context_key)[repeat_index]
+            except IndexError:
+                return None
 
     def get_extra_manager_context(self):
         extra_context = super().get_extra_manager_context()
@@ -1111,7 +1243,8 @@ class WcsCardInfosCell(CardMixin, CellBase):
 
         card_id = self.get_card_id(context)
         if not card_id:
-            if self.card_ids:  # template defined, but result is empty or None
+            if self.card_ids or self.related_card_path:
+                # template or related_card_path defined, but result is empty or None
                 extra_context['card_not_found'] = True
             return extra_context
 
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index 9c9ea76d..d80e2c39 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -164,6 +164,59 @@ WCS_CARDS_DATA = {
             'url': '/backoffice/data/card_model_1/13/',
         },
     ],
+    'card_a': [
+        {
+            'id': 1,
+            'fields': {
+                'cardb_raw': 1,
+                'cardsb_raw': [2, 3],
+                'blockb_raw': [{'cardb_raw': 4}, {'cardb_raw': 5}],
+                'cardc_raw': 6,
+                'cardz_raw': 42,
+            },
+        },
+        {
+            'id': 2,
+            'fields': {
+                'cardb_raw': 1,
+                'cardsb_raw': [2, 3],
+                'blockb_raw': [{'cardb_raw': 4}, {'cardb_raw': 5}],
+                'cardc_raw': 61,  # unknown card_c
+            },
+        },
+        {
+            'id': 3,
+            'fields': {
+                # some missing fields
+                'blockb_raw': [{}],
+            },
+        },
+        {
+            'id': 4,
+            'fields': {
+                # some empty fields
+                'cardb_raw': None,
+                'cardsb_raw': None,
+                'blockb_raw': [{'cardb_raw': None}],
+                'cardc_raw': 7,
+            },
+        },
+    ],
+    'card_b': [{'id': i, 'fields': []} for i in range(1, 12) if i != 6],
+    'card_c': [
+        {
+            'id': 6,
+            'fields': {
+                'cardb_raw': 7,
+                'cardsb_raw': [8, 9],
+                'blockb_raw': [{'cardb_raw': 10}, {'cardb_raw': 11}],
+            },
+        },
+        {
+            'id': 7,
+            'fields': {},
+        },
+    ],
 }
 
 WCS_CARDS_CUSTOM_VIEW_DATA = [
@@ -2385,6 +2438,405 @@ def test_card_cell_render_identifier(mock_send, nocache, app):
     assert cell_resp.text.replace('\n', '') == ''  # empty-cell
 
 
+@mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_card_cell_render_identifier_from_related(mock_send, nocache, app):
+    page = Page.objects.create(title='xxx', slug='foo', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(
+        page=page,
+        placeholder='content',
+        order=0,
+        slug='sluga',
+        carddef_reference='default:card_a',
+        card_ids='1',
+    )
+    cell2 = WcsCardInfosCell.objects.create(
+        page=page, placeholder='content', order=1, slug='slugb', carddef_reference='default:card_b'
+    )
+
+    cell2_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell2.get_reference()},
+    )
+
+    def failing(urls):
+        resp = app.get(page.get_online_url())
+        assert len(resp.context['cells']) >= 2
+        for i in range(0, len(resp.context['cells']) - 1):
+            assert resp.context['cells'][i].pk == cell.pk
+        assert resp.context['cells'][-1].pk == cell2.pk
+        extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+        mock_send.reset_mock()
+        cell_resp = app.get(cell2_url + '?ctx=' + extra_ctx[-1])
+        assert cell_resp.text.replace('\n', '') == ''  # empty-cell
+        assert len(mock_send.call_args_list) == len(urls)
+        for j, url in enumerate(urls):
+            assert url in mock_send.call_args_list[j][0][0].url
+
+    def single(urls):
+        resp = app.get(page.get_online_url())
+        assert len(resp.context['cells']) == 2
+        assert resp.context['cells'][0].pk == cell.pk
+        assert resp.context['cells'][1].pk == cell2.pk
+        assert resp.context['cells'][1].repeat_index == 0
+        extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+        mock_send.reset_mock()
+        cell_resp = app.get(cell2_url + '?ctx=' + extra_ctx[1])
+        assert cell_resp.context['repeat_index'] == 0
+        assert len(mock_send.call_args_list) == len(urls)
+        for j, url in enumerate(urls):
+            assert url in mock_send.call_args_list[j][0][0].url
+
+    def multiple(urls):
+        resp = app.get(page.get_online_url())
+        assert len(resp.context['cells']) >= 3
+        assert resp.context['cells'][0].pk == cell.pk
+        extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+        for i in range(0, len(resp.context['cells']) - 1):
+            assert resp.context['cells'][i + 1].pk == cell2.pk
+            assert resp.context['cells'][i + 1].repeat_index == i
+            mock_send.reset_mock()
+            cell_resp = app.get(cell2_url + '?ctx=' + extra_ctx[i + 1])
+            assert cell_resp.context['repeat_index'] == i
+            assert len(mock_send.call_args_list) == len(urls)
+            for j, url in enumerate(urls):
+                if isinstance(url, list):
+                    assert url[i] in mock_send.call_args_list[j][0][0].url
+                else:
+                    assert url in mock_send.call_args_list[j][0][0].url
+
+    # no cell with this slug
+    cell2.related_card_path = 'slugz/cardb'
+    cell2.save()
+    failing(urls=[])
+
+    # another cell with the same slug
+    cell3 = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=2, slug='sluga')
+    cell2.related_card_path = 'sluga/foo'
+    cell2.save()
+    failing(urls=[])
+    cell3.delete()
+
+    # multiple ids configured on first cell
+    cell.card_ids = '{{ cards|objects:"card_a"|getlist:"id"|join:"," }}'
+    cell.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/list',
+        ]
+    )
+
+    # related_card_path configured on first cell
+    cell.card_ids = '1'  # reset
+    cell.related_path = 'foobar'
+    cell.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+        ]
+    )
+
+    # reset
+    cell.related_path = ''
+    cell.save()
+
+    # another cell as the same slug, but not a WcsCardInfosCell
+    cell3 = WcsCardsCell.objects.create(page=page, placeholder='content', order=2, slug='sluga')
+
+    # direct and single relation (item)
+    cell2.related_card_path = 'sluga/cardb'
+    cell2.save()
+    single(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # and follow cardb relation
+            '/api/cards/card_b/1/',
+        ]
+    )
+    cell3.delete()  # reset
+
+    cell2.related_card_path = 'sluga/cardc/cardb'
+    cell2.save()
+    single(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # get card_c schema
+            '/api/cards/card_c/@schema',
+            # follow cardc relation
+            '/api/cards/card_c/6/',
+            # and follow cardb relation
+            '/api/cards/card_b/7/',
+        ]
+    )
+
+    # direct and multiple relation (items)
+    cell2.related_card_path = 'sluga/cardsb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # and follow cardb relation
+            ['/api/cards/card_b/2/', '/api/cards/card_b/3/'],
+        ]
+    )
+
+    cell2.related_card_path = 'sluga/cardc/cardsb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # get card_c schema
+            '/api/cards/card_c/@schema',
+            # follow cardc relation
+            '/api/cards/card_c/6/',
+            # and follow cardb relation
+            ['/api/cards/card_b/8/', '/api/cards/card_b/9/'],
+        ]
+    )
+
+    # direct and multiple relation through a block
+    cell2.related_card_path = 'sluga/blockb_cardb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # and follow cardb relation
+            ['/api/cards/card_b/4/', '/api/cards/card_b/5/'],
+        ]
+    )
+
+    cell2.related_card_path = 'sluga/cardc/blockb_cardb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # get card_c schema
+            '/api/cards/card_c/@schema',
+            # follow cardc relation
+            '/api/cards/card_c/6/',
+            # and follow cardb relation
+            ['/api/cards/card_b/10/', '/api/cards/card_b/11/'],
+        ]
+    )
+
+    # unknown part in related_card_path
+    cell2.related_card_path = 'sluga/foobar'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+        ]
+    )
+    cell2.related_card_path = 'sluga/cardc/foobar'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # get card_c schema
+            '/api/cards/card_c/@schema',
+            # follow cardc relation
+            '/api/cards/card_c/6/',
+        ]
+    )
+
+    # card data not found
+    cell.card_ids = '42'
+    cell.save()
+    cell2.related_card_path = 'sluga/cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/42/',
+        ]
+    )
+
+    cell.card_ids = '2'
+    cell.save()
+    cell2.related_card_path = 'sluga/cardc/cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/2/',
+            # get card_c schema
+            '/api/cards/card_c/@schema',
+            # follow cardc relation
+            '/api/cards/card_c/61/',
+        ]
+    )
+    # reset
+    cell.card_ids = '1'
+    cell.save()
+
+    # last part has not the correct card slug
+    cell2.related_card_path = 'sluga/cardc'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+        ]
+    )
+
+    # unknown schema
+    cell2.related_card_path = 'sluga/cardz/cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+            # get card_z schema
+            '/api/cards/card_z/@schema',
+        ]
+    )
+
+    # multiple relation of multiple relation
+    cell2.related_card_path = 'sluga/cardsb/reverse:cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/1/',
+        ]
+    )
+
+    # field not found
+    cell.card_ids = '3'
+    cell.save()
+    cell2.related_card_path = 'sluga/cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/3/',
+        ]
+    )
+    cell2.related_card_path = 'sluga/cardc/cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/3/',
+        ]
+    )
+
+    # field empty
+    cell.card_ids = '4'
+    cell.save()
+    cell2.related_card_path = 'sluga/cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/4/',
+        ]
+    )
+
+    # field not found in block
+    cell.card_ids = '3'
+    cell.save()
+    cell2.related_card_path = 'sluga/blockb_cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/3/',
+        ]
+    )
+
+    # field empty in block
+    cell.card_ids = '4'
+    cell.save()
+    cell2.related_card_path = 'sluga/blockb_cardb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_a/4/',
+        ]
+    )
+
+    # reverse relation of item
+    cell.carddef_reference = 'default:card_b'
+    cell.slug = 'slugb'
+    cell.card_ids = '1'
+    cell.related_card_path = ''
+    cell.save()
+    cell2.carddef_reference = 'default:card_a'
+    cell2.slug = 'sluga'
+    cell2.card_ids = ''
+    cell2.related_card_path = 'slugb/reverse:cardb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_b/1/',
+            # get list of card_a with cardb=1
+            '/api/cards/card_a/list?orig=combo&filter-cardb=1',
+            # and follow carda reverse relation
+            ['/api/cards/card_a/%s/' % i for i in range(1, 5)],
+        ]
+    )
+
+    # reverse relation of items
+    cell2.related_card_path = 'slugb/reverse:cardsb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_b/1/',
+            # get list of card_a with cardsb=1
+            '/api/cards/card_a/list?orig=combo&filter-cardsb=1',
+            # and follow carda reverse relation
+            ['/api/cards/card_a/%s/' % i for i in range(1, 5)],
+        ]
+    )
+
+    # reverse relation of item through a block
+    cell2.related_card_path = 'slugb/reverse:blockb_cardb'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_b/1/',
+            # get list of card_a with cardsb=1
+            '/api/cards/card_a/list?orig=combo&filter-blockb_cardb=1',
+            # and follow carda reverse relation
+            ['/api/cards/card_a/%s/' % i for i in range(1, 5)],
+        ]
+    )
+
+    # unknown part in related_card_path
+    cell2.related_card_path = 'slugb/foobar'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_b/1/',
+        ]
+    )
+
+    # multiple relation of multiple relation
+    cell2.related_card_path = 'slugb/reverse:cardb/cardsb'
+    cell2.save()
+    failing(
+        urls=[
+            # get first cell data
+            '/api/cards/card_b/1/',
+        ]
+    )
+
+
 @mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
 def test_card_cell_render_user(mock_send, context, nocache):
     page = Page.objects.create(title='xxx', template_name='standard')
-- 
2.33.0

