From 25217512cfa671fe937bbfa7953560ce29170d41 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 11 Aug 2022 14:01:57 +0200
Subject: [PATCH 3/8] tests: split wcs tests (#68063)

---
 tests/wcs/conftest.py  |   14 +
 tests/wcs/test_all.py  | 2810 ++--------------------------------------
 tests/wcs/test_card.py | 2175 +++++++++++++++++++++++++++++++
 tests/wcs/utils.py     |  413 ++++++
 4 files changed, 2721 insertions(+), 2691 deletions(-)
 create mode 100644 tests/wcs/conftest.py
 create mode 100644 tests/wcs/test_card.py
 create mode 100644 tests/wcs/utils.py

diff --git a/tests/wcs/conftest.py b/tests/wcs/conftest.py
new file mode 100644
index 00000000..db6e64c4
--- /dev/null
+++ b/tests/wcs/conftest.py
@@ -0,0 +1,14 @@
+from importlib import import_module
+
+import pytest
+from django.conf import settings
+from django.test.client import RequestFactory
+
+
+@pytest.fixture
+def context():
+    ctx = {'request': RequestFactory().get('/')}
+    ctx['request'].user = None
+    session_engine = import_module(settings.SESSION_ENGINE)
+    ctx['request'].session = session_engine.SessionStore()
+    return ctx
diff --git a/tests/wcs/test_all.py b/tests/wcs/test_all.py
index abfb5eef..e363d6c2 100644
--- a/tests/wcs/test_all.py
+++ b/tests/wcs/test_all.py
@@ -1,8 +1,5 @@
-import copy
 import json
 import re
-import urllib.parse
-from importlib import import_module
 from unittest import mock
 
 import pytest
@@ -21,7 +18,6 @@ from requests.models import Response
 from combo.apps.search.engines import engines
 from combo.apps.search.models import SearchCell
 from combo.apps.search.utils import index_site, search_site
-from combo.apps.wcs.forms import WcsCardInfoCellDisplayForm
 from combo.apps.wcs.models import (
     BackofficeSubmissionCell,
     CategoriesCell,
@@ -41,425 +37,9 @@ from combo.utils import NothingInCacheException
 from tests.test_manager import login
 from tests.utils import manager_submit_cell
 
-pytestmark = pytest.mark.django_db
-
-
-WCS_FORMDEFS_DATA = [
-    {'slug': 'a-private-form', 'title': 'a private form', 'url': '/a-private-form/'},
-    {'slug': 'a-second-form-title', 'title': 'a second form title', 'url': '/a-second-form-title/'},
-    {'slug': 'form-title', 'title': 'form title', 'url': '/form-title/', 'keywords': ['foo', 'bar']},
-    {'slug': 'third-form-title', 'title': 'Third form title', 'url': '/third-form-title/'},
-]
-
-WCS_CATEGORIES_DATA = [
-    {'slug': 'test-%s' % i, 'title': 'Test %s' % i, 'url': '/test-%s/' % i} for i in [3, 9]
-]
-
-WCS_CATEGORIES_FORMDEFS_DATA = [
-    {
-        'slug': 'form-title',
-        'title': 'form title',
-        'url': '/form-title/',
-        'keywords': ['foo', 'bar'],
-        'count': 42,
-    },
-    {
-        'slug': 'a-second-form-title',
-        'title': 'a second form title',
-        'url': '/a-second-form-title/',
-        'count': 35,
-    },
-]
-
-WCS_USER_FORMS_DATA = [
-    {
-        'name': 'name',
-        'title': 'Title',
-        'url': '/form/1/',
-        'form_receipt_datetime': '2015-01-01T00:00:00',
-        'readable': True,
-        'category_slug': 'test-9',
-    },
-    {'name': 'name', 'url': '/form/2/', 'form_receipt_datetime': '2015-01-01T00:00:00', 'readable': True},
-    {'name': 'name', 'title': 'Title', 'url': '/form/3/', 'form_receipt_datetime': '2015-01-01T00:00:00'},
-]
-
-WCS_FORMS_DATA = [
-    {
-        'form_receipt_datetime': '2019-10-17T16:46:03',
-        'form_url': '/foobar/1',
-        'form_url_backoffice': '/backoffice/management/foobar/1/',
-    },
-    {
-        'form_receipt_datetime': '2019-10-17T16:46:04',
-        'form_url': '/foobar/2',
-        'form_url_backoffice': '/backoffice/management/foobar/2/',
-    },
-]
-
-WCS_USER_DRAFTS_DATA = [
-    {
-        'name': 'name',
-        'title': 'Title',
-        'url': '/form/1/',
-        'form_receipt_datetime': '2015-01-01T00:00:00',
-        'category_slug': 'test-9',
-    },
-    {'name': 'name', 'url': '/form/2/', 'form_receipt_datetime': '2015-01-01T00:00:00'},
-    {'name': 'name', 'title': 'Title', 'url': '/form/3/', 'form_receipt_datetime': '2015-01-01T00:00:00'},
-    {
-        'name': 'name',
-        'title': 'Title',
-        'url': '/form/4/',
-        'form_receipt_datetime': '2015-01-01T00:00:00',
-        'form_status_is_endpoint': True,
-        'category_slug': 'test-9',
-    },
-]
-
-WCS_CARDDEFS_DATA = [
-    {'title': 'Card Model 1', 'slug': 'card_model_1', 'custom_views': [{'id': 'foo', 'text': 'bar'}]},
-    {'title': 'Card Model 2', 'slug': 'card_model_2'},
-    {'title': 'Card Model 3', 'slug': 'card_model_3'},
-    {'title': 'Card A', 'slug': 'card_a'},
-    {'title': 'Card B', 'slug': 'card_b', 'custom_views': [{'id': 'a-custom-view', 'text': 'foo bar'}]},
-    {'title': 'Card C', 'slug': 'card_c'},
-    {'title': 'Card D', 'slug': 'card_d'},
-    {'title': 'Card E', 'slug': 'card_e'},
-]
-
-WCS_CARDS_DATA = {
-    'card_model_1': [
-        {
-            'id': 11,
-            'display_id': '10-11',
-            'display_name': 'Card Model 1 - n°10-11',
-            'digest': 'a a a',
-            'text': 'aa',
-            'url': '/backoffice/data/card_model_1/11/',
-            'fields': {
-                'fielda': '<i>a</i>',
-                'fieldb': True,
-                'fieldc': '2020-09-28',
-                'fieldd': {'filename': 'file.pdf', 'url': 'http://127.0.0.1:8999/download?f=42'},
-                'fielde': "lorem<strong>ipsum\n\nhello'world",
-                'fieldf': 'lorem<strong>ipsum\n\nhello world',
-                'fieldg': 'test@localhost',
-                'fieldh': 'https://www.example.net/',
-                'related': 'Foo Bar',
-                'related_raw': 42,
-                'related_structured': {'id': 42, 'text': 'blah'},
-            },
-        },
-        {
-            'id': 12,
-            'display_id': '10-12',
-            'display_name': 'Card Model 1 - n°10-12',
-            'digest': 'b b b',
-            'text': 'bb',
-            'url': '/backoffice/data/card_model_1/12/',
-        },
-        {
-            'id': 13,
-            'display_id': '10-13',
-            'display_name': 'Card Model 1 - n°10-13',
-            'digest': 'c c c',
-            'text': 'cc',
-            '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': {},
-        },
-    ],
-    'card_f': [
-        {'id': 41, 'fields': {'cardh_raw': 42}},
-    ],
-    'card_g': [
-        {'id': 43, 'fields': {'cardh_raw': 44}},
-    ],
-    'card_h': [
-        {'id': 42, 'fields': {}},
-        {'id': 44, 'fields': {}},
-    ],
-}
-
-WCS_CARDS_CUSTOM_VIEW_DATA = [
-    {
-        'id': 11,
-        'display_id': '10-11',
-        'display_name': 'Card Model 1 - n°10-11',
-        'digest': 'a a a',
-        'text': 'aa',
-        'url': '/backoffice/data/card_model_1/11/',
-    },
-    {
-        'id': 12,
-        'display_id': '10-12',
-        'display_name': 'Card Model 1 - n°10-12',
-        'digest': 'b b b',
-        'text': 'bb',
-        'url': '/backoffice/data/card_model_1/12/',
-    },
-]
-
-WCS_CARDDEF_SCHEMAS = {
-    'card_model_1': {
-        'name': 'Card Model 1',
-        'fields': [
-            {'label': 'Field A', 'varname': 'fielda', 'type': 'string'},
-            {'label': 'Field B', 'varname': 'fieldb', 'type': 'bool'},
-            {'label': 'Field C', 'varname': 'fieldc', 'type': 'date'},
-            {'label': 'Field D', 'varname': 'fieldd', 'type': 'file'},
-            {'label': 'Field E', 'varname': 'fielde', 'type': 'text'},
-            {'label': 'Field F', 'varname': 'fieldf', 'type': 'text', 'pre': True},
-            {'label': 'Field G', 'varname': 'fieldg', 'type': 'email'},
-            {'label': 'Field H', 'varname': 'fieldh', 'type': 'string'},
-            {'label': 'Empty', 'varname': 'empty', 'type': 'string'},
-            {'label': 'Related', 'varname': 'related', 'type': 'item'},
-            {'label': 'Page', 'type': 'page'},
-            {'label': 'Comment', 'type': 'comment'},
-            {'label': 'Title', 'type': 'title'},
-            {'label': 'Subtitle', 'type': 'subtitle'},
-            {'label': 'Empty', 'varname': None, 'type': 'string'},
-        ],
-    },
-    'card_a': {
-        'name': 'Card A',
-        'fields': [
-            {'label': 'Card B', 'varname': 'cardb', 'type': 'item'},
-            {'label': 'Cards B', 'varname': 'cardsb', 'type': 'items'},
-            {'label': 'Block B', 'varname': 'blockb', 'type': 'block:b'},
-            {'label': 'Card C', 'varname': 'cardc', 'type': 'item'},
-        ],
-        'relations': [
-            {'obj': 'carddef:card_b', 'varname': 'cardb', 'type': 'item', 'reverse': False},
-            {'obj': 'carddef:card_b', 'varname': 'cardsb', 'type': 'items', 'reverse': False},
-            {'obj': 'carddef:card_b', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': False},
-            {'obj': 'carddef:card_c', 'varname': 'cardc', 'type': 'item', 'reverse': False},
-            {
-                'obj': 'carddef:card_z',  # unknown card model
-                'varname': 'cardz',
-                'type': 'item',
-                'reverse': False,
-            },
-        ],
-    },
-    'card_b': {
-        'name': 'Card B',
-        'fields': [],
-        'relations': [
-            {'obj': 'carddef:card_a', 'varname': 'cardb', 'type': 'item', 'reverse': True},
-            {'obj': 'carddef:card_a', 'varname': 'cardsb', 'type': 'items', 'reverse': True},
-            {'obj': 'carddef:card_a', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': True},
-            {'obj': 'carddef:card_c', 'varname': 'cardb', 'type': 'item', 'reverse': True},
-            {'obj': 'carddef:card_c', 'varname': 'cardsb', 'type': 'items', 'reverse': True},
-            {'obj': 'carddef:card_c', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': True},
-        ],
-    },
-    'card_c': {
-        'name': 'Card C',
-        'fields': [
-            {'label': 'Card B', 'varname': 'cardb', 'type': 'item'},
-            {'label': 'Cards B', 'varname': 'cardsb', 'type': 'items'},
-            {'label': 'Block B', 'varname': 'blockb', 'type': 'block:b'},
-        ],
-        'relations': [
-            {'obj': 'carddef:card_b', 'varname': 'cardb', 'type': 'item', 'reverse': False},
-            {'obj': 'carddef:card_b', 'varname': 'cardsb', 'type': 'items', 'reverse': False},
-            {'obj': 'carddef:card_b', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': False},
-            {'obj': 'carddef:card_a', 'varname': 'cardc', 'type': 'item', 'reverse': True},
-        ],
-    },
-    'card_d': {
-        'name': 'Card D',
-        'fields': [
-            {'label': 'Card D', 'varname': 'cardd-foo', 'type': 'item'},
-            {'label': 'Card E', 'varname': 'carde-foo', 'type': 'item'},
-        ],
-        'relations': [
-            {'obj': 'carddef:card_d', 'varname': 'cardd-foo', 'type': 'item', 'reverse': False},
-            {'obj': 'carddef:card_d', 'varname': 'cardd-foo', 'type': 'item', 'reverse': True},
-            {'obj': 'carddef:card_e', 'varname': 'carde-foo', 'type': 'item', 'reverse': False},
-        ],
-    },
-    'card_e': {
-        'name': 'Card E',
-        'fields': [
-            {'label': 'Card D', 'varname': 'cardd-bar', 'type': 'item'},
-        ],
-        'relations': [
-            {'obj': 'carddef:card_d', 'varname': 'cardd-bar', 'type': 'item', 'reverse': False},
-            {'obj': 'carddef:card_d', 'varname': 'carde-foo', 'type': 'item', 'reverse': True},
-        ],
-    },
-    'card_f': {
-        'name': 'Card F',
-        'fields': [
-            {'label': 'Card H', 'varname': 'cardh', 'type': 'item'},
-        ],
-        'relations': [
-            {'obj': 'carddef:card_h', 'varname': 'cardh', 'type': 'item', 'reverse': False},
-        ],
-    },
-    'card_g': {
-        'name': 'Card G',
-        'fields': [
-            {'label': 'Card H', 'varname': 'cardh', 'type': 'item'},
-        ],
-        'relations': [
-            {'obj': 'carddef:card_h', 'varname': 'cardh', 'type': 'item', 'reverse': False},
-        ],
-    },
-    'card_h': {
-        'name': 'Card H',
-        'fields': [],
-        'relations': [
-            {'obj': 'carddef:card_f', 'varname': 'cardh', 'type': 'item', 'reverse': True},
-            {'obj': 'carddef:card_g', 'varname': 'cardh', 'type': 'item', 'reverse': True},
-        ],
-    },
-}
-
-
-class MockUser:
-    email = 'foo@example.net'
-    is_authenticated = True
-    is_anonymous = False
-
-    def get_name_id(self):
-        return None
-
-
-class MockUserWithNameId:
-    email = 'foo@example.net'
-    is_authenticated = True
-    is_anonymous = False
-
-    def get_name_id(self):
-        return 'xyz'
-
-
-class MockedRequestResponse(mock.Mock):
-    status_code = 200
-
-    def json(self):
-        return json.loads(self.content)
-
-
-def get_data_from_url(url):
-    if '/api/formdefs/' in url:
-        return WCS_FORMDEFS_DATA
-    if '/api/categories/' in url:
-        if '/formdefs/' in url:
-            return WCS_CATEGORIES_FORMDEFS_DATA
-        return WCS_CATEGORIES_DATA
-    if '/api/user/forms' in url:
-        return WCS_USER_FORMS_DATA
-    if '/api/forms' in url:
-        return WCS_FORMS_DATA
-    if '/api/user/drafts' in url:
-        return WCS_USER_DRAFTS_DATA
-    if '/api/cards/@list' in url:
-        return WCS_CARDDEFS_DATA
-    m_schema = re.match(r'/api/cards/([a-z0-9_]+)/@schema', url)
-    if m_schema:
-        return WCS_CARDDEF_SCHEMAS.get(m_schema.group(1)) or {}
-    m_card = re.match(r'/api/cards/([a-z0-9_]+)/(\d+)/', url)
-    if m_card:
-        try:
-            return [d for d in WCS_CARDS_DATA[m_card.group(1)] if d['id'] == int(m_card.group(2))][0]
-        except IndexError:
-            return {}
-    m_card = re.match(r'/api/cards/([a-z0-9_]+)/([a-z0-9_]+)/(\d+)/', url)  # model/custom-view/id
-    if m_card:
-        try:
-            return [d for d in WCS_CARDS_DATA[m_card.group(1)] if d['id'] == int(m_card.group(3))][0]
-        except IndexError:
-            return {}
-    if 'api/cards/card_model_1/list/foo' in url:
-        return WCS_CARDS_CUSTOM_VIEW_DATA
-    m_list = re.match(r'/api/cards/([a-z0-9_]+)/list', url)
-    if m_list:
-        return WCS_CARDS_DATA.get(m_list.group(1)) or []
-    return []
-
-
-def mocked_requests_send(request, **kwargs):
-    request_url = urllib.parse.urlparse(request.url)
-    data = copy.deepcopy(get_data_from_url(request_url.path))
-
-    if not isinstance(data, list):
-        return MockedRequestResponse(content=json.dumps(data))
-
-    for elem in data:
-        for key in ['url', 'form_url', 'form_url_backoffice']:
-            if key not in elem:
-                continue
-            elem_url = elem[key]
-            elem[key] = '{scheme}://{netloc}{url}'.format(
-                scheme=request_url.scheme, netloc=request_url.netloc, url=elem_url
-            )
+from .utils import MockedRequestResponse, MockUser, MockUserWithNameId, mocked_requests_send
 
-    return MockedRequestResponse(content=json.dumps({'data': data}))
-
-
-@pytest.fixture
-def context():
-    ctx = {'request': RequestFactory().get('/')}
-    ctx['request'].user = None
-    session_engine = import_module(settings.SESSION_ENGINE)
-    ctx['request'].session = session_engine.SessionStore()
-    return ctx
+pytestmark = pytest.mark.django_db
 
 
 @mock.patch('requests.Session.send', side_effect=mocked_requests_send)
@@ -1985,2215 +1565,147 @@ def test_cards_cell_render_user(mock_send, context, nocache):
     assert 'email' not in mock_send.call_args_list[0][0][0].url
 
 
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_setup(mock_send, app, admin_user):
-    page = Page.objects.create(
-        title='xxx', slug='test_card_cell_save_cache', template_name='standard', sub_slug='foobar'
-    )
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    form_class = cell.get_default_form_class()
-    form = form_class(instance=cell)
-    assert form.fields['carddef_reference'].widget.choices == [
-        ('default:card_model_1', 'test : Card Model 1'),
-        ('default:card_model_1:foo', 'test : Card Model 1 - bar'),
-        ('default:card_model_2', 'test : Card Model 2'),
-        ('default:card_model_3', 'test : Card Model 3'),
-        ('default:card_a', 'test : Card A'),
-        ('default:card_b', 'test : Card B'),
-        ('default:card_b:a-custom-view', 'test : Card B - foo bar'),
-        ('default:card_c', 'test : Card C'),
-        ('default:card_d', 'test : Card D'),
-        ('default:card_e', 'test : Card E'),
-        ('other:card_model_1', 'test2 : Card Model 1'),
-        ('other:card_model_1:foo', 'test2 : Card Model 1 - bar'),
-        ('other:card_model_2', 'test2 : Card Model 2'),
-        ('other:card_model_3', 'test2 : Card Model 3'),
-        ('other:card_a', 'test2 : Card A'),
-        ('other:card_b', 'test2 : Card B'),
-        ('other:card_b:a-custom-view', 'test2 : Card B - foo bar'),
-        ('other:card_c', 'test2 : Card C'),
-        ('other:card_d', 'test2 : Card D'),
-        ('other:card_e', 'test2 : Card E'),
-    ]
-
-    form_display = WcsCardInfoCellDisplayForm(instance=cell)
-    assert 'customize_display' not in form_display.fields
-    assert 'custom_schema' not in form_display.fields
-
+def test_tracking_code_cell(app, nocache):
+    page = Page(title='One', slug='index', template_name='standard')
+    page.save()
+    cell = TrackingCodeInputCell(page=page, placeholder='content', order=0)
     cell.save()
-    assert 'customize_display' not in form_display.fields
-    assert 'custom_schema' not in form_display.fields
 
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-    form_display = WcsCardInfoCellDisplayForm(instance=cell)
-    assert 'customize_display' in form_display.fields
-    assert 'custom_schema' in form_display.fields
-    assert 'customize_display' not in form_display.initial
-    assert form_display.initial['custom_schema'] == {}
+    resp = app.get('/')
+    resp.form['code'] = 'FOOBAR'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=200)
+        requests_get.return_value = mock_json
+        resp = resp.form.submit()
+    assert len(requests_get.call_args_list) == 2
+    assert requests_get.call_args_list[0][0][0] == '/api/code/FOOBAR'
+    assert requests_get.call_args_list[1][0][0] == '/api/code/FOOBAR'
+    remote_service_urls = [c[1]['remote_service']['url'] for c in requests_get.call_args_list]
+    assert set(remote_service_urls) == {'http://127.0.0.1:8999/', 'http://127.0.0.2:8999/'}
+    assert resp.status_code == 302
+    resp = resp.follow()
+    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
 
-    cell.carddef_reference = 'default:card_model_1:foo'
-    cell.save()
-    form_display = WcsCardInfoCellDisplayForm(instance=cell)
-    assert 'customize_display' in form_display.fields
-    assert 'custom_schema' in form_display.fields
-    assert 'customize_display' not in form_display.initial
-    assert form_display.initial['custom_schema'] == {}
+    resp = app.get('/')
+    resp.form['code'] = 'FOO?BAR?bad<code>'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=200)
+        requests_get.return_value = mock_json
+        resp = resp.form.submit()
+    assert requests_get.call_args_list[0][0][0] == '/api/code/FOO%3FBAR%3FBAD%3CCODE%3E'
+    assert resp.status_code == 302
+    resp = resp.follow()
+    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
 
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
+    resp = app.get('/')
+    resp.form['code'] = 'CNPHNTFB'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        requests_get.return_value = MockedRequestResponse(
+            content=json.dumps({'err': 0, 'load_url': 'http://127.0.0.2:8999/code/CNPHNTFB/load'})
+        )
+        resp = resp.form.submit()
+    assert resp.status_code == 302
+    assert resp.location == 'http://127.0.0.2:8999/code/CNPHNTFB/load'
 
-    cell.custom_schema = {'cells': [{'varname': 'foo', 'display_mode': 'value'}]}
-    cell.save()
-    form_display = WcsCardInfoCellDisplayForm(instance=cell)
-    assert 'customize_display' in form_display.fields
-    assert 'custom_schema' in form_display.fields
-    assert form_display.initial['customize_display'] is True
-    assert form_display.initial['custom_schema'] == {
-        'cells': [
-            {'varname': 'foo', 'field_content': 'value', 'display_mode': 'text', 'empty_value': '@empty@'}
+    # space/case
+    resp = app.get('/')
+    resp.form['code'] = ' cnphntfb'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        requests_get.side_effect = [
+            mock.Mock(status_code=200),
+            MockedRequestResponse(
+                content=json.dumps({'err': 0, 'load_url': 'http://127.0.0.2:8999/code/CNPHNTFB/load'})
+            ),
         ]
-    }
-
-    WcsCardInfosCell.objects.all().delete()
-
-    # check adding a cell from the UI
-    app = login(app)
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    cell_add_url = [x for x in resp.html.find_all('option') if x.text == 'Card Information Cell'][0].get(
-        'data-add-url'
-    )
-    resp = app.get(cell_add_url).follow()
-    cell = WcsCardInfosCell.objects.all().first()
-    manager_submit_cell(resp.forms[0])  # will save card model
-    cell.refresh_from_db()
+        resp = resp.form.submit()
+    assert requests_get.call_args_list[0][0][0] == '/api/code/CNPHNTFB'
+    assert requests_get.call_args_list[1][0][0] == '/api/code/CNPHNTFB'
+    assert resp.status_code == 302
+    assert resp.location == 'http://127.0.0.2:8999/code/CNPHNTFB/load'
 
-    # check getting back to uncustomized display reset the schema
-    cell.custom_schema = {
-        'cells': [
-            {'varname': 'foo', 'field_content': 'value', 'display_mode': 'text', 'empty_value': '@empty@'}
-        ]
-    }
+    # lock cell to a single site
+    cell.wcs_site = 'default'
     cell.save()
+    resp = app.get('/')
+    resp.form['code'] = 'CNPHNTFB'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=200)
+        requests_get.return_value = mock_json
+        resp = resp.form.submit()
+    assert len(requests_get.call_args_list) == 1
+    assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://127.0.0.1:8999/'
+    resp = resp.follow()
+    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
 
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-customize_display' % cell.get_reference()].value == 'on'
-    resp.forms[0]['c%s-customize_display' % cell.get_reference()].value = False
-    manager_submit_cell(resp.forms[0])
-    cell.refresh_from_db()
-    assert cell.custom_schema == {}
-
-    cell.custom_schema = {
-        'cells': [
-            {'varname': 'foo', 'field_content': 'value', 'display_mode': 'text', 'empty_value': '@empty@'}
-        ]
-    }
+    # unknown wcs_site
+    cell.wcs_site = 'unknown'
     cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-display_mode' % cell.get_reference()].value == 'card'
-    resp.forms[0]['c%s-display_mode' % cell.get_reference()].value = 'table'
-    manager_submit_cell(resp.forms[0])
-    cell.refresh_from_db()
-    assert cell.custom_schema == {}
-
-    assert cell.related_card_path == ''
-    assert cell.card_ids == ''
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == '--'
-    resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = '42'
-    manager_submit_cell(resp.forms[0])
-    cell.refresh_from_db()
-    assert cell.related_card_path == ''
-    assert cell.card_ids == ''
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == '--'
-    resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value = ''
-    resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = '42'
-    manager_submit_cell(resp.forms[0])
-    cell.refresh_from_db()
-    assert cell.related_card_path == ''
-    assert cell.card_ids == '42'
-
-    # current page has a sub_slug, '--' option is present
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert '--' in [o[0] for o in resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options]
+    resp = app.get('/')
+    resp.form['code'] = 'CNPHNTFB'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=200)
+        requests_get.return_value = mock_json
+        resp = resp.form.submit()
+    assert len(requests_get.call_args_list) == 0
+    resp = resp.follow()
+    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
 
-    # current_page has no sub_slug, but parent page has one
-    parent_page = Page.objects.create(
-        title='parent', slug='parent', template_name='standard', sub_slug='foobar'
-    )
-    page.parent = parent_page
-    page.sub_slug = ''
-    page.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert '--' in [o[0] for o in resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options]
+    # simulate cell being displayed on a different site
+    resp = app.get('/')
+    resp.form['url'] = 'http://example.org/'
+    resp.form['code'] = 'CNPHNTFB'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=200)
+        requests_get.return_value = mock_json
+        resp = resp.form.submit()
+    assert resp.location == 'http://example.org/?unknown-tracking-code'
 
-    # no sub_slug
-    parent_page.sub_slug = ''
-    parent_page.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert '--' not in [o[0] for o in resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options]
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == ''
-    resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = ''
-    resp = resp.forms[0].submit()
-    assert resp.context['form'].errors == {'card_ids': ['This field is required.']}
+    resp = app.get('/')
+    resp.form['url'] = 'http://example.org/?foo=bar'
+    resp.form['code'] = 'CNPHNTFB'
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=200)
+        requests_get.return_value = mock_json
+        resp = resp.form.submit()
+    assert resp.location == 'http://example.org/?foo=bar&unknown-tracking-code'
 
+    # redirect to an unknown site
+    resp = app.get('/')
+    resp.form['url'] = 'http://example.net/'
+    resp.form['code'] = 'CNPHNTFB'
+    resp = resp.form.submit(status=400)
 
-def test_card_cell_custom_schema_migration():
-    cell = WcsCardInfosCell()
+    # error handling
+    resp = app.get('/')
+    resp.form['cell'] = '9999'
+    resp.form['code'] = 'CNPHNTFB'
+    resp = resp.form.submit(status=400)
 
-    cell.custom_schema = {
-        'cells': [{'varname': 'some-field', 'display_mode': 'label', 'cell_size': 'foobar'}]
-    }
-    assert cell.get_custom_schema() == {
-        'cells': [
-            {
-                'varname': 'some-field',
-                'field_content': 'label',
-                'display_mode': 'text',
-                'empty_value': '@empty@',
-                'cell_size': 'foobar',
-            }
-        ]
-    }
-    cell.custom_schema = {'cells': [{'varname': 'some-field', 'display_mode': 'value'}]}
-    assert cell.get_custom_schema() == {
-        'cells': [
-            {
-                'varname': 'some-field',
-                'field_content': 'value',
-                'display_mode': 'text',
-                'empty_value': '@empty@',
-            }
-        ]
-    }
-    cell.custom_schema = {'cells': [{'varname': 'some-field', 'display_mode': 'label-and-value'}]}
-    assert cell.get_custom_schema() == {
-        'cells': [
-            {
-                'varname': 'some-field',
-                'field_content': 'label-and-value',
-                'display_mode': 'text',
-                'empty_value': '@empty@',
-            }
-        ]
-    }
-    cell.custom_schema = {'cells': [{'varname': 'some-field', 'display_mode': 'title'}]}
-    assert cell.get_custom_schema() == {
-        'cells': [
-            {
-                'varname': 'some-field',
-                'field_content': 'value',
-                'display_mode': 'title',
-                'empty_value': '@empty@',
-            }
-        ]
-    }
+    resp = app.get('/')
+    resp.form['cell'] = 'xxxx'
+    resp.form['code'] = 'CNPHNTFB'
+    resp = resp.form.submit(status=400)
 
-    cell.custom_schema = {
-        'cells': [
-            {'varname': '@custom@', 'template': 'foobar', 'display_mode': 'label', 'cell_size': 'foobar'}
-        ]
-    }
-    assert cell.get_custom_schema() == {
-        'cells': [
-            {'varname': '@custom@', 'template': 'foobar', 'display_mode': 'label', 'cell_size': 'foobar'}
-        ]
-    }
-    cell.custom_schema = {'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'value'}]}
-    assert cell.get_custom_schema() == {
-        'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'text'}]
-    }
-    cell.custom_schema = {'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'title'}]}
-    assert cell.get_custom_schema() == {
-        'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'title'}]
-    }
+    resp = app.post(reverse('wcs-tracking-code'), params={'cell': cell.id}, status=400)
 
 
 @mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_save_cache(mock_send):
-    page = Page.objects.create(title='xxx', slug='test_card_cell_save_cache', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    assert cell.get_additional_label() is None
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-    assert cell.cached_title == 'Card Model 1'
-    assert cell.cached_json != {}
-    assert cell.get_additional_label() == 'Card Model 1'
-    # make sure cached attributes are removed from serialized pages
-    assert 'cached_' not in json.dumps(page.get_serialized_page())
-
-    # artificially change title and json
-    WcsCardInfosCell.objects.filter(pk=cell.pk).update(cached_title='XXX', cached_json={})
-    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_title == 'XXX'
-    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_json == {}
-    # run update db cache
-    appconfig = apps.get_app_config('wcs')
-    appconfig.update_db_cache()
-    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_title == 'Card Model 1'
-    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_json != {}
-
+def test_tracking_code_cell_check_validity(mock_send):
+    page = Page.objects.create(title='xxx', slug='test', template_name='standard')
+    cell = TrackingCodeInputCell.objects.create(page=page, placeholder='content', order=0)
 
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_validity(mock_send):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=0)
+    # invalid wcs_site
+    cell.wcs_site = 'invalid'
+    cell.save()
+    cell.check_validity()
     validity_info = ValidityInfo.objects.latest('pk')
-    assert validity_info.invalid_reason_code == 'wcs_card_not_defined'
+    assert validity_info.invalid_reason_code == 'wcs_site_not_found'
     assert validity_info.invalid_since is not None
 
-    cell.carddef_reference = 'default:card_model_1'
+    # valid wcs_site
+    cell.wcs_site = 'default'
     cell.save()
-    assert ValidityInfo.objects.exists() is False
-
-    # can not retrieve data, don't set cell as invalid
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_resp = Response()
-        mock_resp.status_code = 500
-        requests_get.return_value = mock_resp
-        cell.save()
-    assert ValidityInfo.objects.exists() is False
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        requests_get.side_effect = ConnectionError()
-        cell.save()
-    assert ValidityInfo.objects.exists() is False
-
-    # can not retrieve carddefs, don't set cell as invalid
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_resp = Response()
-        mock_resp.status_code = 404
-        requests_get.return_value = mock_resp
-        cell.save()
-    assert ValidityInfo.objects.exists() is False
-
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_resp = Response()
-        mock_resp.json = lambda *a, **k: {'err': 1, 'err_class': 'Page not found'}
-        mock_resp.status_code = 404
-        requests_get.return_value = mock_resp
-        cell.carddef_reference = 'default:foobar'
-        cell.save()
-    validity_info = ValidityInfo.objects.latest('pk')
-    assert validity_info.invalid_reason_code == 'wcs_card_not_found'
-    assert validity_info.invalid_since is not None
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_check_validity(mock_send):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell.objects.create(
-        page=page,
-        placeholder='content',
-        order=0,
-        carddef_reference='default:card_a',
-        card_ids='1',
-    )
-    cell2 = WcsCardInfosCell.objects.create(
-        page=page, placeholder='content', order=1, carddef_reference='default:card_b'
-    )
-
-    # no related_card_path
-    cell2.check_validity()
-    assert ValidityInfo.objects.exists() is False
-
-    # correct related_card_path but sluga is not defined
-    cell2.related_card_path = 'sluga/cardb'
-    cell2.save()
-    cell2.check_validity()
-    validity_info = ValidityInfo.objects.latest('pk')
-    assert validity_info.invalid_reason_code == 'wcs_card_relation_not_found'
-    assert validity_info.invalid_since is not None
-
-    # sluga is now defined
-    cell.slug = 'sluga'
-    cell.save()
-    cell2.check_validity()
-    assert ValidityInfo.objects.exists() is False
-
-    # bad related_card_path
-    cell2.related_card_path = 'sluga/foobar'
-    cell2.save()
-    cell2.check_validity()
-    validity_info = ValidityInfo.objects.latest('pk')
-    assert validity_info.invalid_reason_code == 'wcs_card_relation_not_found'
-    assert validity_info.invalid_since is not None
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_manager_card_cell(mock_send, app, admin_user):
-    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard', sub_slug='foobar')
-    cell = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=0)
-
-    app = login(app)
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert 'application/json' not in resp
-
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert '<script id="cell-%s-card-schema-default:card_model_1" type="application/json">' % cell.pk in resp
-
-    assert ('data-cell-reference="%s"' % cell.get_reference()) in resp.text
-    assert cell.without_user is False
-    assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value == 'on'
-    resp.forms[0]['c%s-with_user' % cell.get_reference()].value = False
-    manager_submit_cell(resp.forms[0])
-    cell.refresh_from_db()
-    assert cell.without_user is True
-    assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value is None
-
-    # card with relations
-    cell.carddef_reference = 'default:card_a'
-    cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    # but only one cell on the page, no relations to follow
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-
-    # all cards
-    cell.related_card_path = '__all__'
-    cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', False, 'Identifier from page URL'),
-        ('__all__', True, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-
-    # add a second cell, related to the first card model
-    cell.related_card_path = ''
-    cell.save()
-    cell2 = WcsCardInfosCell.objects.create(
-        page=page, placeholder='content', order=1, carddef_reference='default:card_b'
-    )
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    # still no relation to follow
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-    # no cell with id and slug
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-
-    # set a slug on first cell
-    cell.slug = 'sluga'
-    cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    # still no relation to follow
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-    # multiple relations to follow
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('sluga/cardb', False, 'sluga/cardb'),
-        ('sluga/cardsb', False, 'sluga/cardsb'),
-        ('sluga/blockb_cardb', False, 'sluga/blockb_cardb'),
-        ('sluga/cardc/cardb', False, 'sluga/cardc/cardb'),
-        ('sluga/cardc/cardsb', False, 'sluga/cardc/cardsb'),
-        ('sluga/cardc/blockb_cardb', False, 'sluga/cardc/blockb_cardb'),
-    ]
-
-    # set a list of ids on first cell
-    cell.card_ids = '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}'
-    cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    # still no relation to follow
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', False, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', True, 'Other Card Identifiers'),
-    ]
-    # can not user cell with multiple ids as reference
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-
-    # define a slug on second cell
-    cell.card_ids = ''
-    cell.save()
-    cell2.slug = 'slugb'
-    cell2.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    # multiple relations to follow
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('slugb/reverse:cardb', False, 'slugb/cardb (reverse)'),
-        ('slugb/reverse:cardsb', False, 'slugb/cardsb (reverse)'),
-        ('slugb/reverse:blockb_cardb', False, 'slugb/blockb_cardb (reverse)'),
-    ]
-    # still multiple relations to follow
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('sluga/cardb', False, 'sluga/cardb'),
-        ('sluga/cardsb', False, 'sluga/cardsb'),
-        ('sluga/blockb_cardb', False, 'sluga/blockb_cardb'),
-        ('sluga/cardc/cardb', False, 'sluga/cardc/cardb'),
-        ('sluga/cardc/cardsb', False, 'sluga/cardc/cardsb'),
-        ('sluga/cardc/blockb_cardb', False, 'sluga/cardc/blockb_cardb'),
-    ]
-
-    # set a related_path on cell2
-    resp.forms[1]['c%s-related_card_path' % cell2.get_reference()] = 'sluga/cardb'
-    resp.forms[1]['c%s-card_ids' % cell2.get_reference()] = 'foobar'
-    resp = resp.forms[1].submit()
-    cell2.refresh_from_db()
-    assert cell2.related_card_path == 'sluga/cardb'
-    assert cell2.card_ids == ''
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    # no more relation to follow
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-    ]
-    # still multiple relations to follow
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', False, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('sluga/cardb', True, 'sluga/cardb'),
-        ('sluga/cardsb', False, 'sluga/cardsb'),
-        ('sluga/blockb_cardb', False, 'sluga/blockb_cardb'),
-        ('sluga/cardc/cardb', False, 'sluga/cardc/cardb'),
-        ('sluga/cardc/cardsb', False, 'sluga/cardc/cardsb'),
-        ('sluga/cardc/blockb_cardb', False, 'sluga/cardc/blockb_cardb'),
-    ]
-    resp.forms[1].submit()
-    cell2.refresh_from_db()
-    assert cell2.related_card_path == 'sluga/cardb'
-    assert cell2.card_ids == ''
-
-    # check circular relations
-    cell.slug = 'sluge'
-    cell.carddef_reference = 'default:card_e'
-    cell.save()
-    cell2.carddef_reference = 'default:card_d'
-    cell2.slug = 'slugd'
-    cell2.related_card_path = ''
-    cell2.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('slugd/cardd-foo/carde-foo', False, 'slugd/cardd-foo/carde-foo'),
-        ('slugd/carde-foo', False, 'slugd/carde-foo'),
-    ]
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('sluge/cardd-bar', False, 'sluge/cardd-bar'),
-        ('sluge/reverse:carde-foo', False, 'sluge/carde-foo (reverse)'),
-    ]
-
-    cell.slug = 'slugd'
-    cell.carddef_reference = 'default:card_d'
-    cell.save()
-    cell2.carddef_reference = 'default:card_d'
-    cell2.slug = 'slugd-bis'
-    cell2.related_card_path = ''
-    cell2.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('slugd-bis/cardd-foo', False, 'slugd-bis/cardd-foo'),
-        ('slugd-bis/reverse:cardd-foo', False, 'slugd-bis/cardd-foo (reverse)'),
-        ('slugd-bis/carde-foo/cardd-bar', False, 'slugd-bis/carde-foo/cardd-bar'),
-        ('slugd-bis/carde-foo/reverse:carde-foo', False, 'slugd-bis/carde-foo/carde-foo (reverse)'),
-    ]
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('slugd/cardd-foo', False, 'slugd/cardd-foo'),
-        ('slugd/reverse:cardd-foo', False, 'slugd/cardd-foo (reverse)'),
-        ('slugd/carde-foo/cardd-bar', False, 'slugd/carde-foo/cardd-bar'),
-        ('slugd/carde-foo/reverse:carde-foo', False, 'slugd/carde-foo/carde-foo (reverse)'),
-    ]
-
-    cell.slug = 'sluge'
-    cell.carddef_reference = 'default:card_e'
-    cell.save()
-    cell2.carddef_reference = 'default:card_e'
-    cell2.slug = 'sluge-bis'
-    cell2.related_card_path = ''
-    cell2.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('sluge-bis/cardd-bar/carde-foo', False, 'sluge-bis/cardd-bar/carde-foo'),
-    ]
-    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
-        ('--', True, 'Identifier from page URL'),
-        ('__all__', False, 'All cards'),
-        ('', False, 'Other Card Identifiers'),
-        ('sluge/cardd-bar/carde-foo', False, 'sluge/cardd-bar/carde-foo'),
-    ]
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_manager_card_cell_tabs(mock_send, app, admin_user):
-    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard', sub_slug='foobar')
-    cell = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=0)
-
-    app = login(app)
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert not resp.pyquery('[data-tab-slug="general"] select[name$="title_type"]')
-    assert not resp.pyquery('[data-tab-slug="general"] input[name$="custom_title"]')
-    assert not resp.pyquery('[data-tab-slug="general"] input[name$="limit"]')
-    assert not resp.pyquery('#tab-%s-general.pk-tabs--button-marker' % cell.get_reference())
-    assert resp.pyquery('[data-tab-slug="appearance"] select[name$="title_type"]')
-    assert resp.pyquery('[data-tab-slug="appearance"] input[name$="custom_title"]')
-    assert not resp.pyquery('[data-tab-slug="appearance"] input[name$="customize_display"]')
-    assert resp.pyquery('[data-tab-slug="display"] input[name$="limit"]')
-
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-    resp = app.get('/manage/pages/%s/' % page.pk)
-    assert resp.pyquery('#tab-%s-general.pk-tabs--button-marker' % cell.get_reference())
-    assert resp.pyquery('[data-tab-slug="display"] input[name$="customize_display"]')
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_load(mock_send):
-    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-    site_export = [page.get_serialized_page()]
-    cell.delete()
-    assert not Page.objects.get(pk=page.pk).get_cells()
-    Page.load_serialized_pages(site_export)
-    page = Page.objects.get(slug='test_cards')
-    cells = page.get_cells()
-    assert len(cells) == 1
-    cell = cells[0]
-    assert cell.cached_title == 'Card Model 1'
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render(mock_send, context, app):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.title_type = 'manual'
-    cell.custom_title = 'Foo bar {{ card.fields.title }}'
-    cell.save()
-
-    # carddef_reference is not defined
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-    assert '<h2>Card Model 1</h2>' not in result
-    assert '<p>Unknown Card</p>' in result
-
-    # card id not in context
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-    del context['card_model_1_id']
-    assert 'card_model_1_id' not in context
-    result = cell.render(context)
-    assert '<h2>Card Model 1</h2>' in result  # default value
-    assert '<p>Unknown Card</p>' in result
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_resp = Response()
-        mock_resp.status_code = 500
-        requests_get.return_value = mock_resp
-        result = cell.render(context)
-    assert '<h2>Card Model 1</h2>' in result  # default value
-    assert '<p>Unknown Card</p>' in result
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        requests_get.side_effect = ConnectionError()
-        result = cell.render(context)
-    assert '<h2>Card Model 1</h2>' in result  # default value
-    assert '<p>Unknown Card</p>' in result
-
-    context.pop('title')
-    cell.title_type = 'auto'
-    cell.save()
-    mock_send.reset_mock()
-    result = cell.render(context)
-    assert '<h2>Card Model 1 - aa</h2>' in result
-    assert PyQuery(result).find('.label:contains("Field A") + .value').text() == '<i>a</i>'
-    assert PyQuery(result).find('.label:contains("Field B") + .value').text() == 'yes'
-    assert PyQuery(result).find('.label:contains("Field C") + .value').text() == 'Sept. 28, 2020'
-    assert PyQuery(result).find('.label:contains("Related") + .value').text() == 'Foo Bar'
-    assert 'related_raw' not in result
-    assert 'related_structured' not in result
-    assert PyQuery(result).find('.label:contains("Field D") + .value a').text() == 'file.pdf'
-
-    context.pop('title')
-    cell.title_type = 'manual'
-    cell.custom_title = '<b>Foo bar {{ card.fields.fielda }}</b>'
-    cell.save()
-    assert cell.get_additional_label() == '&lt;b&gt;Foo bar {{ card.fields.fielda }}&lt;/b&gt;'
-    result = cell.render(context)
-    assert '<h2>&lt;b&gt;Foo bar &lt;i&gt;a&lt;/i&gt;&lt;/b&gt;</h2>' in result
-
-    context.pop('title')
-    cell.custom_title = '{{ foobar }}'
-    cell.save()
-    result = cell.render(context)
-    assert '<h2>Card Model 1 - aa</h2>' in result  # empty value from template, default value
-
-    context.pop('title')
-    cell.custom_title = '{% if %}'
-    cell.save()
-    result = cell.render(context)
-    assert '<h2>Card Model 1 - aa</h2>' in result  # template error, default value
-
-    context.pop('title')
-    cell.title_type = 'empty'
-    cell.save()
-    result = cell.render(context)
-    assert '<h2>' not in result
-
-    # test available context
-    cell.title_type = 'manual'
-    cell.custom_title = 'X{{ site_base }}Y'
-    cell.card_ids = '11'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert '<h2>Xhttp://testserverY</h2>' in cell_resp
-
-    cell.card_ids = '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}'
-    cell.title_type = 'manual'
-    cell.custom_title = 'Foo bar X{{ repeat_index }}Y'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 3
-
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    for i in range(0, 3):
-        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
-        assert '<h2>Foo bar X%sY</h2>' % i in cell_resp
-    # again, without ajax: urls are already in cache
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 3
-    for i in range(0, 3):
-        assert '<h2>Foo bar X%sY</h2>' % i in resp
-    assert 'data-paginate-by="10"' in resp
-
-    cell.limit = 42
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 3
-    for i in range(0, 3):
-        assert '<h2>Foo bar X%sY</h2>' % i in resp
-    assert 'data-paginate-by="42"' in resp
-
-    # using custom view
-    cell.carddef_reference = 'default:card_model_1:foo'
-    cell.save()
-    result = cell.render(context)
-    assert 'Foo bar X0Y' in result
-
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_resp = Response()
-        mock_resp.status_code = 404
-        requests_get.return_value = mock_resp
-        result = cell.render(context)
-
-    # nothing, hide cell
-    assert not result.strip()
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_text_field(mock_send, context):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-
-    # field E is split in paragraphs
-    assert (
-        PyQuery(result).find('.label:contains("Field E") + .value p:first-child').text().strip()
-        == 'lorem<strong>ipsum'
-    )
-    assert (
-        PyQuery(result).find('.label:contains("Field E") + .value p:last-child').text().strip()
-        == "hello'world"
-    )
-
-    # field F is put in a <pre>
-    assert (
-        PyQuery(result).find('.label:contains("Field F") + .value pre').text()
-        == 'lorem<strong>ipsum hello world'
-    )
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_email_field(mock_send, context):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = 'default:card_model_1'
-    cell.save()
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-
-    assert PyQuery(result).find('.label:contains("Field G") + .value a').text() == 'test@localhost'
-
-    assert (
-        PyQuery(result).find('.label:contains("Field G") + .value a').attr['href'] == 'mailto:test@localhost'
-    )
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_string_with_url_field(mock_send, context):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = 'default:card_model_1'
-    cell.custom_title = 'Foo bar {{ card.fields.title }}'
-    cell.save()
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-
-    assert PyQuery(result).find('.label:contains("Field H") + .value a').text() == 'https://www.example.net/'
-
-    assert (
-        PyQuery(result).find('.label:contains("Field H") + .value a').attr['href']
-        == 'https://www.example.net/'
-    )
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_custom_schema_card_field(mock_send, context):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell.objects.create(
-        page=page,
-        placeholder='content',
-        order=0,
-        carddef_reference='default:card_model_1',
-        custom_schema={'cells': [{'varname': 'fielda', 'field_content': 'value', 'display_mode': 'title'}]},
-    )
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-    assert PyQuery(result).find('h3').text() == '<i>a</i>'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fielda',
-        'field_content': 'value',
-        'display_mode': 'subtitle',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('h4').text() == '<i>a</i>'
-
-    cell.custom_schema['cells'][0] = {'varname': 'fielda', 'field_content': 'label', 'display_mode': 'title'}
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('h3').text() == 'Field A'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fielda',
-        'field_content': 'label',
-        'display_mode': 'subtitle',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('h4').text() == 'Field A'
-
-    cell.custom_schema['cells'][0] = {'varname': 'fielda', 'field_content': 'label', 'display_mode': 'text'}
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field A'
-
-    cell.custom_schema['cells'][0] = {'varname': 'fielda', 'field_content': 'value', 'display_mode': 'text'}
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.value').text() == '<i>a</i>'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fielda',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field A'
-    assert PyQuery(result).find('.value').text() == '<i>a</i>'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fieldb',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field B'
-    assert PyQuery(result).find('.value').text() == 'yes'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fieldc',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field C'
-    assert PyQuery(result).find('.value').text() == 'Sept. 28, 2020'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'related',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Related'
-    assert PyQuery(result).find('.value').text() == 'Foo Bar'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fieldd',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field D'
-    assert PyQuery(result).find('.value').text() == 'file.pdf'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fielde',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    # check multiline text field is rendered with multiple paragraphs
-    # (first line "lorem<strong>ipsum" and last line ("hello'world")
-    # and the content is kept properly escaped.
-    assert PyQuery(result).find('.label').text() == 'Field E'
-    assert PyQuery(result).find('.value p:first-child').text().strip() == 'lorem<strong>ipsum'
-    assert PyQuery(result).find('.value p:last-child').text().strip() == "hello'world"
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fieldf',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field F'
-    assert PyQuery(result).find('.value pre').text() == 'lorem<strong>ipsum hello world'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fieldg',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field G'
-    assert PyQuery(result).find('.value a').text() == 'test@localhost'
-    assert PyQuery(result).find('.value a').attr['href'] == 'mailto:test@localhost'
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'fieldh',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Field H'
-    assert PyQuery(result).find('.value a').text() == 'https://www.example.net/'
-    assert PyQuery(result).find('.value a').attr['href'] == 'https://www.example.net/'
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_custom_schema_card_empty_field(mock_send, context):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell.objects.create(
-        page=page,
-        placeholder='content',
-        order=0,
-        carddef_reference='default:card_model_1',
-        custom_schema={
-            'cells': [
-                {
-                    'varname': 'empty',
-                    'field_content': 'label-and-value',
-                    'display_mode': 'text',
-                    'empty_value': '@skip@',
-                }
-            ]
-        },
-    )
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-    assert len(PyQuery(result).find('.cell--body > div > div')) == 0
-    assert PyQuery(result).find('.label') == []
-    assert PyQuery(result).find('.value') == []
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'empty',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-        'empty_value': '@empty@',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert len(PyQuery(result).find('.cell--body > div > div')) == 1
-    assert PyQuery(result).find('.label').text() == 'Empty'
-    assert PyQuery(result).find('.value').text() == ''
-
-    cell.custom_schema['cells'][0] = {
-        'varname': 'empty',
-        'field_content': 'label-and-value',
-        'display_mode': 'text',
-        'empty_value': 'Custom text',
-    }
-    cell.save()
-    result = cell.render(context)
-    assert len(PyQuery(result).find('.cell--body > div > div')) == 1
-    assert PyQuery(result).find('.label').text() == 'Empty'
-    assert PyQuery(result).find('.value').text() == 'Custom text'
-
-    for field_content in ['label', 'value']:
-        for display_mode in ['text', 'title', 'subtitle']:
-            if display_mode == 'title':
-                html_tag = 'h3'
-            elif display_mode == 'subtitle':
-                html_tag = 'h4'
-            elif display_mode == 'text' and field_content == 'label':
-                html_tag = '.label'
-            elif display_mode == 'text' and field_content == 'value':
-                html_tag = '.value'
-            cell.custom_schema['cells'][0] = {
-                'varname': 'empty',
-                'field_content': field_content,
-                'display_mode': display_mode,
-                'empty_value': '@skip@',
-            }
-            cell.save()
-            result = cell.render(context)
-            assert len(PyQuery(result).find('.cell--body > div > div')) == 0
-            assert PyQuery(result).find(html_tag) == []
-
-            cell.custom_schema['cells'][0] = {
-                'varname': 'empty',
-                'field_content': field_content,
-                'display_mode': display_mode,
-                'empty_value': '@empty@',
-            }
-            cell.save()
-            result = cell.render(context)
-            assert len(PyQuery(result).find('.cell--body > div > div')) == 1
-            assert PyQuery(result).find(html_tag).text() == ('Empty' if field_content == 'label' else '')
-
-            cell.custom_schema['cells'][0] = {
-                'varname': 'empty',
-                'field_content': field_content,
-                'display_mode': display_mode,
-                'empty_value': 'Custom text',
-            }
-            cell.save()
-            result = cell.render(context)
-            assert len(PyQuery(result).find('.cell--body > div > div')) == 1
-            assert PyQuery(result).find(html_tag).text() == (
-                'Empty' if field_content == 'label' else 'Custom text'
-            )
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_custom_schema_custom_entry(mock_send, context, app):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell.objects.create(
-        page=page,
-        placeholder='content',
-        order=0,
-        carddef_reference='default:card_model_1',
-        custom_schema={
-            'cells': [
-                {
-                    'varname': '@custom@',
-                    'template': "<b>Foo</b> bar'baz {{ card.fields.fielde }}",
-                    'display_mode': 'title',
-                },
-            ]
-        },
-    )
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-    assert '&lt;b&gt;Foo&lt;/b&gt;' in result
-    assert PyQuery(result).find('h3').text() == "<b>Foo</b> bar'baz lorem<strong>ipsum hello'world"
-
-    # test context
-    cell.custom_schema['cells'][0][
-        'template'
-    ] = '{{ card.fields.fielda }} - {{ card.fields.related }} ({{ card.fields.related_structured.id }})'
-    cell.custom_schema['cells'][0]['display_mode'] = 'subtitle'
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('h4').text() == '<i>a</i> - Foo Bar (42)'
-
-    # test display_mode & filters in template
-    cell.custom_schema = {
-        'cells': [
-            {'varname': '@custom@', 'template': 'Foo bar baz', 'display_mode': 'label'},
-            {
-                'varname': '@custom@',
-                'template': '{{ card.fields.related|split:" "|join:"," }}',
-                'display_mode': 'text',
-            },
-        ]
-    }
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.label').text() == 'Foo bar baz'
-    assert PyQuery(result).find('.value').text() == 'Foo,Bar'
-
-    # test available context
-    cell.card_ids = '11'
-    cell.custom_schema = {
-        'cells': [
-            {
-                'varname': '@custom@',
-                'template': 'Foo bar baz {% make_public_url url="http://127.0.0.1:8999/" %}',
-                'display_mode': 'label',
-            },
-        ]
-    }
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert '/api/wcs/file/' in PyQuery(cell_resp.text).find('.label').text()
-
-    cell.custom_schema = {
-        'cells': [
-            {'varname': '@custom@', 'template': 'Foo bar baz X{{ site_base }}Y', 'display_mode': 'label'},
-        ]
-    }
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert PyQuery(cell_resp.text).find('.label').text() == 'Foo bar baz Xhttp://testserverY'
-
-    cell.card_ids = '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}'
-    cell.custom_schema = {
-        'cells': [
-            {'varname': '@custom@', 'template': 'Foo bar baz X{{ repeat_index }}Y', 'display_mode': 'label'},
-        ]
-    }
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 3
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-    for i in range(0, 3):
-        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
-        assert PyQuery(cell_resp.text).find('.label').text() == 'Foo bar baz X%sY' % i
-
-    # custom schema but empty
-    cell.custom_schema = {'cells': []}
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('div.cell--body') == []
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_custom_schema_link_entry(mock_send, context, app):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell.objects.create(
-        page=page,
-        placeholder='content',
-        order=0,
-        carddef_reference='default:card_model_1',
-        custom_schema={
-            'cells': [
-                {
-                    'varname': '@link@',
-                    'url_template': '/foo/bar/{{ card.fields.related_structured.id }}/',
-                    'template': '{{ card.fields.fielda }} - {{ card.fields.related }}',
-                    'display_mode': 'link',
-                },
-            ]
-        },
-    )
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    result = cell.render(context)
-    assert PyQuery(result).find('.value a').text() == '<i>a</i> - Foo Bar'
-    assert PyQuery(result).find('.value a').attr['href'] == '/foo/bar/42/'
-    assert PyQuery(result).find('.value a').attr['class'] is None
-
-    cell.custom_schema['cells'][0]['display_mode'] = 'button'
-    cell.save()
-    result = cell.render(context)
-    assert PyQuery(result).find('.value a').text() == '<i>a</i> - Foo Bar'
-    assert PyQuery(result).find('.value a').attr['href'] == '/foo/bar/42/'
-    assert PyQuery(result).find('.value a').attr['class'] == 'pk-button'
-
-    cell.custom_schema['cells'][0][
-        'url_template'
-    ] = '{{ site_base }}/foo/bar/{{ card.fields.related_structured.id }}/'
-    cell.custom_schema['cells'][0]['template'] = '<b>{{ card.fields.fielda }}</b> - {{ card.fields.related }}'
-    cell.card_ids = '11'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert (
-        '<div class="value"><a href="http://testserver/foo/bar/42/" class="pk-button">&lt;b&gt;&lt;i&gt;a&lt;/i&gt;&lt;/b&gt; - Foo Bar</a></div>'
-        in cell_resp
-    )
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_all_cards(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,
-        carddef_reference='default:card_model_1',
-        related_card_path='__all__',
-    )
-
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-
-    # check url called
-    mock_send.reset_mock()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 3
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    for i in range(0, 3):
-        assert resp.context['cells'][i].pk == cell.pk
-        assert resp.context['cells'][i].repeat_index == i
-        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
-        assert cell_resp.context['repeat_index'] == i
-    assert len(mock_send.call_args_list) == 7
-    # page rendering
-    assert '/api/cards/card_model_1/list' in mock_send.call_args_list[0][0][0].url
-    # cell rendering
-    for i in range(0, 3):
-        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 1][0][0].url
-        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 2][0][0].url
-        assert 'filter-internal-id' not in mock_send.call_args_list[i * 2 + 2][0][0].url
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_identifier(mock_send, nocache, app):
-    page = Page.objects.create(
-        title='xxx', slug='foo', template_name='standard', sub_slug='(?P<card_model_1_id>[a-z0-9]+)'
-    )
-    cell = WcsCardInfosCell.objects.create(
-        page=page, placeholder='content', order=0, carddef_reference='default:card_model_1'
-    )
-
-    cell_url = reverse(
-        'combo-public-ajax-page-cell',
-        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
-    )
-
-    # check url called
-    mock_send.reset_mock()
-    resp = app.get(page.get_online_url() + '11/')
-    assert len(resp.context['cells']) == 1
-    assert resp.context['cells'][0].pk == cell.pk
-    assert resp.context['cells'][0].repeat_index == 0
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert cell_resp.context['repeat_index'] == 0
-    assert 'Card Model 1' in cell_resp
-    assert '<p>Unknown Card</p>' not in cell_resp
-    assert len(mock_send.call_args_list) == 1
-    assert '/api/cards/card_model_1/11/' in mock_send.call_args_list[0][0][0].url
-
-    # with identifiers
-    page.sub_slug = ''
-    page.save()
-    cell.card_ids = '42'
-    cell.save()
-    mock_send.reset_mock()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    assert resp.context['cells'][0].pk == cell.pk
-    assert resp.context['cells'][0].repeat_index == 0
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert cell_resp.context['repeat_index'] == 0
-    assert 'Card Model 1' in cell_resp
-    assert '<p>Unknown Card</p>' in cell_resp
-    assert len(mock_send.call_args_list) == 1
-    assert '/api/cards/card_model_1/42/' in mock_send.call_args_list[0][0][0].url
-
-    cell.card_ids = '42, , 35'
-    cell.save()
-    mock_send.reset_mock()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 2
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    for i in range(0, 2):
-        assert resp.context['cells'][i].pk == cell.pk
-        assert resp.context['cells'][i].repeat_index == i
-        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
-        assert cell_resp.context['repeat_index'] == i
-        assert 'Card Model 1' in cell_resp
-        assert '<p>Unknown Card</p>' in cell_resp
-    assert len(mock_send.call_args_list) == 2
-    for i in range(0, 2):
-        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i][0][0].url
-        assert '&filter-internal-id=42&filter-internal-id=35&' in mock_send.call_args_list[i][0][0].url
-
-    cell.card_ids = '{% cards|objects:"card_model_1"|last|get:"id" %}'  # syntax error
-    cell.save()
-    mock_send.reset_mock()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert cell_resp.text.replace('\n', '') == ''  # empty-cell
-
-    cell.card_ids = '{{ cards|objects:"card_model_1"|last|get:"id" }}'
-    cell.save()
-    mock_send.reset_mock()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    assert resp.context['cells'][0].pk == cell.pk
-    assert resp.context['cells'][0].repeat_index == 0
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert cell_resp.context['repeat_index'] == 0
-    assert len(mock_send.call_args_list) == 3
-    # page rendering
-    assert '/api/cards/card_model_1/list' in mock_send.call_args_list[0][0][0].url
-    # cell rendering
-    assert '/api/cards/card_model_1/list' in mock_send.call_args_list[1][0][0].url
-    assert '/api/cards/card_model_1/13' in mock_send.call_args_list[2][0][0].url
-
-    def test_card_ids():
-        mock_send.reset_mock()
-        resp = app.get(page.get_online_url())
-        assert len(resp.context['cells']) == 3
-        extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-        for i in range(0, 3):
-            assert resp.context['cells'][i].pk == cell.pk
-            assert resp.context['cells'][i].repeat_index == i
-            cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
-            assert cell_resp.context['repeat_index'] == i
-        assert len(mock_send.call_args_list) == 7
-        # page rendering
-        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[0][0][0].url
-        # cell rendering
-        card_ids = [c['id'] for c in WCS_CARDS_DATA['card_model_1']]
-        filters = '&%s&' % '&'.join('filter-internal-id=%s' % cid for cid in card_ids)
-        for i in range(0, 3):
-            assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 1][0][0].url
-            assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 2][0][0].url
-            assert filters in mock_send.call_args_list[i * 2 + 2][0][0].url
-
-    for card_ids in [
-        '{% for card in cards|objects:"card_model_1" %}{{ card.id }},{% endfor %}',
-        '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}',
-    ]:
-        cell.card_ids = card_ids
-        cell.save()
-        test_card_ids()
-
-        cell.card_ids = '{{ var1 }}'
-        cell.save()
-        page.extra_variables = {'var1': card_ids}
-        page.save()
-        test_card_ids()
-        page.extra_variables = {}
-        page.save()
-
-    # with a card_ids template, but result is empty
-    cell.card_ids = '{{ foo }}'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
-    assert cell_resp.text.replace('\n', '') == ''  # empty-cell
-
-
-@mock.patch('requests.Session.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_parts in enumerate(urls):
-            if not isinstance(url_parts, tuple):
-                url_parts = (url_parts,)
-            for url_part in url_parts:
-                assert url_part 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']) >= 2
-        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_parts in enumerate(urls):
-                if not isinstance(url_parts, tuple):
-                    url_parts = (url_parts,)
-                for url_part in url_parts:
-                    assert url_part 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/',  # check user access
-            '/api/cards/card_b/1/',  # get card
-        ]
-    )
-    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/',  # check user access
-            '/api/cards/card_b/7/',  # get card
-        ]
-    )
-
-    # change cell ordering - cell with slug is after cell with related
-    cell.order = 42
-    cell.save()
-    # no error, but it does not work as expected: both cells has slug.
-    app.get(page.get_online_url(), status=200)
-    # remove slug of second cell
-    cell2.slug = ''
-    cell2.save()
-    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/',  # check user access
-        '/api/cards/card_b/7/',  # get card
-    ]
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 2
-    assert resp.context['cells'][0].pk == cell2.pk
-    assert resp.context['cells'][0].repeat_index == 0
-    assert resp.context['cells'][1].pk == cell.pk
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    mock_send.reset_mock()
-    cell_resp = app.get(cell2_url + '?ctx=' + extra_ctx[0])
-    assert cell_resp.context['repeat_index'] == 0
-    assert len(mock_send.call_args_list) == len(urls)
-    for j, url_parts in enumerate(urls):
-        if not isinstance(url_parts, tuple):
-            url_parts = (url_parts,)
-        for url_part in url_parts:
-            assert url_part in mock_send.call_args_list[j][0][0].url
-
-    # reset
-    cell.order = 0  # reset
-    cell.save()
-    cell2.slug = 'slugb'
-    cell2.save()
-
-    # test with custom_view
-    cell2.carddef_reference = 'default:card_b:a-custom-view'
-    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/a-custom-view/7/',  # check user access
-            '/api/cards/card_b/a-custom-view/7/',  # get card
-        ]
-    )
-
-    # direct and multiple relation (items)
-    cell2.carddef_reference = 'default:card_b'  # reset
-    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/list', '&filter-internal-id=2&filter-internal-id=3&'),  # check user access
-            ('/api/cards/card_b/list', '&filter-internal-id=2&filter-internal-id=3&'),  # get card
-        ]
-    )
-
-    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/list', '&filter-internal-id=8&filter-internal-id=9&'),  # check user access
-            ('/api/cards/card_b/list', '&filter-internal-id=8&filter-internal-id=9&'),  # get card
-        ]
-    )
-
-    # 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/list', '&filter-internal-id=4&filter-internal-id=5&'),  # check user access
-            ('/api/cards/card_b/list', '&filter-internal-id=4&filter-internal-id=5&'),  # get card
-        ]
-    )
-
-    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/list', '&filter-internal-id=10&filter-internal-id=11&'),  # check user access
-            ('/api/cards/card_b/list', '&filter-internal-id=10&filter-internal-id=11&'),  # get card
-        ]
-    )
-
-    # 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
-            # check user access
-            (
-                '/api/cards/card_a/list',
-                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
-            ),
-            # get card
-            (
-                '/api/cards/card_a/list',
-                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
-            ),
-        ]
-    )
-
-    # 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
-            # check user access
-            (
-                '/api/cards/card_a/list',
-                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
-            ),
-            # get card
-            (
-                '/api/cards/card_a/list',
-                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
-            ),
-        ]
-    )
-
-    # 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
-            # check user access
-            (
-                '/api/cards/card_a/list',
-                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
-            ),
-            # get card
-            (
-                '/api/cards/card_a/list',
-                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
-            ),
-        ]
-    )
-
-    # 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/',
-        ]
-    )
-
-    # reverse relation with many models using the same varname
-    cell.carddef_reference = 'default:card_h'
-    cell.slug = 'slugh'
-    cell.card_ids = '42'
-    cell.related_card_path = ''
-    cell.save()
-    cell2.carddef_reference = 'default:card_f'
-    cell2.slug = 'slugf'
-    cell2.card_ids = ''
-    cell2.related_card_path = 'slugh/reverse:cardh'
-    cell2.save()
-    multiple(
-        urls=[
-            # get first cell data
-            '/api/cards/card_h/42/',
-            # get list of card_f with cardf=42
-            '/api/cards/card_f/list?orig=combo&filter-cardh=42',
-            # and follow cardf reverse relation
-            '/api/cards/card_f/41/',  # check user access
-            '/api/cards/card_f/41/',  # get card
-        ]
-    )
-
-    cell.card_ids = '44'
-    cell.related_card_path = ''
-    cell.save()
-    cell2.carddef_reference = 'default:card_g'
-    cell2.slug = 'slugg'
-    cell2.card_ids = ''
-    cell2.related_card_path = 'slugh/reverse:cardh'
-    cell2.save()
-    multiple(
-        urls=[
-            # get first cell data
-            '/api/cards/card_h/44/',
-            # get list of card_g with cardf=44
-            '/api/cards/card_g/list?orig=combo&filter-cardh=44',
-            # and follow cardf reverse relation
-            '/api/cards/card_g/43/',  # check user access
-            '/api/cards/card_g/43/',  # get card
-        ]
-    )
-
-
-@pytest.mark.parametrize('carddef_reference', ['default:card_model_1', 'default:card_model_1:foo'])
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_only_for_user(mock_send, context, carddef_reference):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = carddef_reference
-    cell.only_for_user = False
-    cell.save()
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-
-    assert cell.is_visible(request=context['request']) is True
-    context['request'].user = MockUserWithNameId()
-    assert cell.is_visible(request=context['request']) is True
-
-    cell.only_for_user = True
-    cell.save()
-    context['request'].user = None
-    assert cell.is_visible(request=context['request']) is False
-    context['request'].user = MockUserWithNameId()
-    assert cell.is_visible(request=context['request']) is True
-
-    cache.clear()
-    context['synchronous'] = True  # to get fresh content
-    context['request'].user = None
-
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'filter-user-uuid' not in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = MockUser()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'filter-user-uuid' not in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = MockUserWithNameId()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'filter-user-uuid=xyz' in mock_send.call_args_list[0][0][0].url
-
-
-@pytest.mark.parametrize('carddef_reference', ['default:card_model_1', 'default:card_model_1:foo'])
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_cell_render_user(mock_send, context, nocache, carddef_reference):
-    page = Page.objects.create(title='xxx', template_name='standard')
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = carddef_reference
-    cell.save()
-
-    context['card_model_1_id'] = 11
-    request = RequestFactory().get('/')
-    cell.modify_global_context(context, request)
-    cell.repeat_index = 0
-    context['synchronous'] = True  # to get fresh content
-
-    assert context['request'].user is None
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'NameID=&' in mock_send.call_args_list[0][0][0].url
-    assert 'email=&' in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = AnonymousUser()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'NameID=&' in mock_send.call_args_list[0][0][0].url
-    assert 'email=&' in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = MockUser()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'email=foo%40example.net' in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = MockUserWithNameId()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'NameID=xyz' in mock_send.call_args_list[0][0][0].url
-
-    cell.without_user = True
-    cell.save()
-
-    context['request'].user = None
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'NameID' not in mock_send.call_args_list[0][0][0].url
-    assert 'email' not in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = MockUser()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'NameID' not in mock_send.call_args_list[0][0][0].url
-    assert 'email' not in mock_send.call_args_list[0][0][0].url
-
-    context['request'].user = MockUserWithNameId()
-    mock_send.reset_mock()
-    cell.render(context)
-    assert 'NameID' not in mock_send.call_args_list[0][0][0].url
-    assert 'email' not in mock_send.call_args_list[0][0][0].url
-
-
-def test_tracking_code_cell(app, nocache):
-    page = Page(title='One', slug='index', template_name='standard')
-    page.save()
-    cell = TrackingCodeInputCell(page=page, placeholder='content', order=0)
-    cell.save()
-
-    resp = app.get('/')
-    resp.form['code'] = 'FOOBAR'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_json = mock.Mock(status_code=200)
-        requests_get.return_value = mock_json
-        resp = resp.form.submit()
-    assert len(requests_get.call_args_list) == 2
-    assert requests_get.call_args_list[0][0][0] == '/api/code/FOOBAR'
-    assert requests_get.call_args_list[1][0][0] == '/api/code/FOOBAR'
-    remote_service_urls = [c[1]['remote_service']['url'] for c in requests_get.call_args_list]
-    assert set(remote_service_urls) == {'http://127.0.0.1:8999/', 'http://127.0.0.2:8999/'}
-    assert resp.status_code == 302
-    resp = resp.follow()
-    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
-
-    resp = app.get('/')
-    resp.form['code'] = 'FOO?BAR?bad<code>'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_json = mock.Mock(status_code=200)
-        requests_get.return_value = mock_json
-        resp = resp.form.submit()
-    assert requests_get.call_args_list[0][0][0] == '/api/code/FOO%3FBAR%3FBAD%3CCODE%3E'
-    assert resp.status_code == 302
-    resp = resp.follow()
-    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
-
-    resp = app.get('/')
-    resp.form['code'] = 'CNPHNTFB'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        requests_get.return_value = MockedRequestResponse(
-            content=json.dumps({'err': 0, 'load_url': 'http://127.0.0.2:8999/code/CNPHNTFB/load'})
-        )
-        resp = resp.form.submit()
-    assert resp.status_code == 302
-    assert resp.location == 'http://127.0.0.2:8999/code/CNPHNTFB/load'
-
-    # space/case
-    resp = app.get('/')
-    resp.form['code'] = ' cnphntfb'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        requests_get.side_effect = [
-            mock.Mock(status_code=200),
-            MockedRequestResponse(
-                content=json.dumps({'err': 0, 'load_url': 'http://127.0.0.2:8999/code/CNPHNTFB/load'})
-            ),
-        ]
-        resp = resp.form.submit()
-    assert requests_get.call_args_list[0][0][0] == '/api/code/CNPHNTFB'
-    assert requests_get.call_args_list[1][0][0] == '/api/code/CNPHNTFB'
-    assert resp.status_code == 302
-    assert resp.location == 'http://127.0.0.2:8999/code/CNPHNTFB/load'
-
-    # lock cell to a single site
-    cell.wcs_site = 'default'
-    cell.save()
-    resp = app.get('/')
-    resp.form['code'] = 'CNPHNTFB'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_json = mock.Mock(status_code=200)
-        requests_get.return_value = mock_json
-        resp = resp.form.submit()
-    assert len(requests_get.call_args_list) == 1
-    assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://127.0.0.1:8999/'
-    resp = resp.follow()
-    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
-
-    # unknown wcs_site
-    cell.wcs_site = 'unknown'
-    cell.save()
-    resp = app.get('/')
-    resp.form['code'] = 'CNPHNTFB'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_json = mock.Mock(status_code=200)
-        requests_get.return_value = mock_json
-        resp = resp.form.submit()
-    assert len(requests_get.call_args_list) == 0
-    resp = resp.follow()
-    assert '<li class="error">The tracking code could not been found.</li>' in resp.text
-
-    # simulate cell being displayed on a different site
-    resp = app.get('/')
-    resp.form['url'] = 'http://example.org/'
-    resp.form['code'] = 'CNPHNTFB'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_json = mock.Mock(status_code=200)
-        requests_get.return_value = mock_json
-        resp = resp.form.submit()
-    assert resp.location == 'http://example.org/?unknown-tracking-code'
-
-    resp = app.get('/')
-    resp.form['url'] = 'http://example.org/?foo=bar'
-    resp.form['code'] = 'CNPHNTFB'
-    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
-        mock_json = mock.Mock(status_code=200)
-        requests_get.return_value = mock_json
-        resp = resp.form.submit()
-    assert resp.location == 'http://example.org/?foo=bar&unknown-tracking-code'
-
-    # redirect to an unknown site
-    resp = app.get('/')
-    resp.form['url'] = 'http://example.net/'
-    resp.form['code'] = 'CNPHNTFB'
-    resp = resp.form.submit(status=400)
-
-    # error handling
-    resp = app.get('/')
-    resp.form['cell'] = '9999'
-    resp.form['code'] = 'CNPHNTFB'
-    resp = resp.form.submit(status=400)
-
-    resp = app.get('/')
-    resp.form['cell'] = 'xxxx'
-    resp.form['code'] = 'CNPHNTFB'
-    resp = resp.form.submit(status=400)
-
-    resp = app.post(reverse('wcs-tracking-code'), params={'cell': cell.id}, status=400)
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_tracking_code_cell_check_validity(mock_send):
-    page = Page.objects.create(title='xxx', slug='test', template_name='standard')
-    cell = TrackingCodeInputCell.objects.create(page=page, placeholder='content', order=0)
-
-    # invalid wcs_site
-    cell.wcs_site = 'invalid'
-    cell.save()
-    cell.check_validity()
-    validity_info = ValidityInfo.objects.latest('pk')
-    assert validity_info.invalid_reason_code == 'wcs_site_not_found'
-    assert validity_info.invalid_since is not None
-
-    # valid wcs_site
-    cell.wcs_site = 'default'
-    cell.save()
-    cell.check_validity()
+    cell.check_validity()
     assert ValidityInfo.objects.exists() is False
 
 
@@ -4480,66 +1992,6 @@ def test_manager_link_list_cell_duplicate():
     assert new_item.cached_json == item.cached_json
 
 
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_cell_condition(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,
-        carddef_reference='default:card_model_1',
-        card_ids='{{ cards|objects:"card_model_1"|last|get:"id" }}',
-    )
-
-    cell.condition = 'cards|objects:"card_model_1"|getlist:"id"|list'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-
-    cell.condition = 'cards|objects:"card_model_1"|getlist:"id"|get:42'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context.get('cells') or []) == 0
-
-    page.extra_variables = {'var1': '{{ cards|objects:"card_model_1"|getlist:"id"|list }}'}
-    page.save()
-    cell.condition = 'var1'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-
-    page.extra_variables = {'var1': '{{ cards|objects:"card_model_1"|getlist:"id"|get:42|default:"" }}'}
-    page.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context.get('cells') or []) == 0
-
-    page.extra_variables = {
-        'var1': '{{ cards|objects:"unknown"|first|get:"id"|default:"" }}',
-        'var2': '{{ cards|objects:"card_model_1"|first|get:"id"|default:"" }}',
-    }
-    page.save()
-    cell.condition = 'not var1'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    cell.condition = 'var2'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-    cell.condition = 'not var2'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context.get('cells') or []) == 0
-    cell.condition = 'not var1 and not var2'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context.get('cells') or []) == 0
-    cell.condition = 'not var1 and var2'
-    cell.save()
-    resp = app.get(page.get_online_url())
-    assert len(resp.context['cells']) == 1
-
-
 @mock.patch('requests.Session.send', side_effect=mocked_requests_send)
 def test_link_list_cell_condition(mock_send, nocache, app):
     page = Page.objects.create(title='xxx', slug='foo', template_name='standard')
@@ -4766,27 +2218,3 @@ def test_search_external_forms_links(mock_send, context):
     request.user = AnonymousUser()
     hits = search_site(request, 'form')
     assert len(hits) == 2
-
-
-@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
-def test_card_file_redirection(mock_send, app):
-    page = Page(title='One', slug='one', template_name='standard')
-    page.save()
-    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
-    cell.carddef_reference = 'default:card_model_1'
-    cell.card_ids = '11'
-    cell.save()
-    resp = app.get('/one/')
-    ajax_cell_url = PyQuery(resp.text).find('[data-ajax-cell-url]').attr['data-ajax-cell-url']
-    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
-    resp = app.get(ajax_cell_url + '?ctx=' + extra_ctx[0])
-    file_url = PyQuery(resp.text).find('[download]').attr['href']
-    resp = app.get(file_url)
-    assert 'download?f=42' in resp.location
-    assert '&signature=' in resp.location
-
-    # invalid crypto
-    resp = app.get(file_url[:-2] + 'X/', status=403)
-
-    # invalid session key
-    resp = app.get(file_url.replace('file/', 'file/X'), status=403)
diff --git a/tests/wcs/test_card.py b/tests/wcs/test_card.py
new file mode 100644
index 00000000..f4d1723f
--- /dev/null
+++ b/tests/wcs/test_card.py
@@ -0,0 +1,2175 @@
+import json
+import re
+from unittest import mock
+
+import pytest
+from django.apps import apps
+from django.contrib.auth.models import AnonymousUser
+from django.core.cache import cache
+from django.test.client import RequestFactory
+from django.urls import reverse
+from pyquery import PyQuery
+from requests.exceptions import ConnectionError
+from requests.models import Response
+
+from combo.apps.wcs.forms import WcsCardInfoCellDisplayForm
+from combo.apps.wcs.models import WcsCardInfosCell
+from combo.data.models import Page, TextCell, ValidityInfo
+from tests.test_manager import login
+from tests.utils import manager_submit_cell
+
+from .utils import WCS_CARDS_DATA, MockUser, MockUserWithNameId, mocked_requests_send
+
+pytestmark = pytest.mark.django_db
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_setup(mock_send, app, admin_user):
+    page = Page.objects.create(
+        title='xxx', slug='test_card_cell_save_cache', template_name='standard', sub_slug='foobar'
+    )
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    form_class = cell.get_default_form_class()
+    form = form_class(instance=cell)
+    assert form.fields['carddef_reference'].widget.choices == [
+        ('default:card_model_1', 'test : Card Model 1'),
+        ('default:card_model_1:foo', 'test : Card Model 1 - bar'),
+        ('default:card_model_2', 'test : Card Model 2'),
+        ('default:card_model_3', 'test : Card Model 3'),
+        ('default:card_a', 'test : Card A'),
+        ('default:card_b', 'test : Card B'),
+        ('default:card_b:a-custom-view', 'test : Card B - foo bar'),
+        ('default:card_c', 'test : Card C'),
+        ('default:card_d', 'test : Card D'),
+        ('default:card_e', 'test : Card E'),
+        ('other:card_model_1', 'test2 : Card Model 1'),
+        ('other:card_model_1:foo', 'test2 : Card Model 1 - bar'),
+        ('other:card_model_2', 'test2 : Card Model 2'),
+        ('other:card_model_3', 'test2 : Card Model 3'),
+        ('other:card_a', 'test2 : Card A'),
+        ('other:card_b', 'test2 : Card B'),
+        ('other:card_b:a-custom-view', 'test2 : Card B - foo bar'),
+        ('other:card_c', 'test2 : Card C'),
+        ('other:card_d', 'test2 : Card D'),
+        ('other:card_e', 'test2 : Card E'),
+    ]
+
+    form_display = WcsCardInfoCellDisplayForm(instance=cell)
+    assert 'customize_display' not in form_display.fields
+    assert 'custom_schema' not in form_display.fields
+
+    cell.save()
+    assert 'customize_display' not in form_display.fields
+    assert 'custom_schema' not in form_display.fields
+
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    form_display = WcsCardInfoCellDisplayForm(instance=cell)
+    assert 'customize_display' in form_display.fields
+    assert 'custom_schema' in form_display.fields
+    assert 'customize_display' not in form_display.initial
+    assert form_display.initial['custom_schema'] == {}
+
+    cell.carddef_reference = 'default:card_model_1:foo'
+    cell.save()
+    form_display = WcsCardInfoCellDisplayForm(instance=cell)
+    assert 'customize_display' in form_display.fields
+    assert 'custom_schema' in form_display.fields
+    assert 'customize_display' not in form_display.initial
+    assert form_display.initial['custom_schema'] == {}
+
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+
+    cell.custom_schema = {'cells': [{'varname': 'foo', 'display_mode': 'value'}]}
+    cell.save()
+    form_display = WcsCardInfoCellDisplayForm(instance=cell)
+    assert 'customize_display' in form_display.fields
+    assert 'custom_schema' in form_display.fields
+    assert form_display.initial['customize_display'] is True
+    assert form_display.initial['custom_schema'] == {
+        'cells': [
+            {'varname': 'foo', 'field_content': 'value', 'display_mode': 'text', 'empty_value': '@empty@'}
+        ]
+    }
+
+    WcsCardInfosCell.objects.all().delete()
+
+    # check adding a cell from the UI
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    cell_add_url = [x for x in resp.html.find_all('option') if x.text == 'Card Information Cell'][0].get(
+        'data-add-url'
+    )
+    resp = app.get(cell_add_url).follow()
+    cell = WcsCardInfosCell.objects.all().first()
+    manager_submit_cell(resp.forms[0])  # will save card model
+    cell.refresh_from_db()
+
+    # check getting back to uncustomized display reset the schema
+    cell.custom_schema = {
+        'cells': [
+            {'varname': 'foo', 'field_content': 'value', 'display_mode': 'text', 'empty_value': '@empty@'}
+        ]
+    }
+    cell.save()
+
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-customize_display' % cell.get_reference()].value == 'on'
+    resp.forms[0]['c%s-customize_display' % cell.get_reference()].value = False
+    manager_submit_cell(resp.forms[0])
+    cell.refresh_from_db()
+    assert cell.custom_schema == {}
+
+    cell.custom_schema = {
+        'cells': [
+            {'varname': 'foo', 'field_content': 'value', 'display_mode': 'text', 'empty_value': '@empty@'}
+        ]
+    }
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-display_mode' % cell.get_reference()].value == 'card'
+    resp.forms[0]['c%s-display_mode' % cell.get_reference()].value = 'table'
+    manager_submit_cell(resp.forms[0])
+    cell.refresh_from_db()
+    assert cell.custom_schema == {}
+
+    assert cell.related_card_path == ''
+    assert cell.card_ids == ''
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == '--'
+    resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = '42'
+    manager_submit_cell(resp.forms[0])
+    cell.refresh_from_db()
+    assert cell.related_card_path == ''
+    assert cell.card_ids == ''
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == '--'
+    resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value = ''
+    resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = '42'
+    manager_submit_cell(resp.forms[0])
+    cell.refresh_from_db()
+    assert cell.related_card_path == ''
+    assert cell.card_ids == '42'
+
+    # current page has a sub_slug, '--' option is present
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert '--' in [o[0] for o in resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options]
+
+    # current_page has no sub_slug, but parent page has one
+    parent_page = Page.objects.create(
+        title='parent', slug='parent', template_name='standard', sub_slug='foobar'
+    )
+    page.parent = parent_page
+    page.sub_slug = ''
+    page.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert '--' in [o[0] for o in resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options]
+
+    # no sub_slug
+    parent_page.sub_slug = ''
+    parent_page.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert '--' not in [o[0] for o in resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options]
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].value == ''
+    resp.forms[0]['c%s-card_ids' % cell.get_reference()].value = ''
+    resp = resp.forms[0].submit()
+    assert resp.context['form'].errors == {'card_ids': ['This field is required.']}
+
+
+def test_card_cell_custom_schema_migration():
+    cell = WcsCardInfosCell()
+
+    cell.custom_schema = {
+        'cells': [{'varname': 'some-field', 'display_mode': 'label', 'cell_size': 'foobar'}]
+    }
+    assert cell.get_custom_schema() == {
+        'cells': [
+            {
+                'varname': 'some-field',
+                'field_content': 'label',
+                'display_mode': 'text',
+                'empty_value': '@empty@',
+                'cell_size': 'foobar',
+            }
+        ]
+    }
+    cell.custom_schema = {'cells': [{'varname': 'some-field', 'display_mode': 'value'}]}
+    assert cell.get_custom_schema() == {
+        'cells': [
+            {
+                'varname': 'some-field',
+                'field_content': 'value',
+                'display_mode': 'text',
+                'empty_value': '@empty@',
+            }
+        ]
+    }
+    cell.custom_schema = {'cells': [{'varname': 'some-field', 'display_mode': 'label-and-value'}]}
+    assert cell.get_custom_schema() == {
+        'cells': [
+            {
+                'varname': 'some-field',
+                'field_content': 'label-and-value',
+                'display_mode': 'text',
+                'empty_value': '@empty@',
+            }
+        ]
+    }
+    cell.custom_schema = {'cells': [{'varname': 'some-field', 'display_mode': 'title'}]}
+    assert cell.get_custom_schema() == {
+        'cells': [
+            {
+                'varname': 'some-field',
+                'field_content': 'value',
+                'display_mode': 'title',
+                'empty_value': '@empty@',
+            }
+        ]
+    }
+
+    cell.custom_schema = {
+        'cells': [
+            {'varname': '@custom@', 'template': 'foobar', 'display_mode': 'label', 'cell_size': 'foobar'}
+        ]
+    }
+    assert cell.get_custom_schema() == {
+        'cells': [
+            {'varname': '@custom@', 'template': 'foobar', 'display_mode': 'label', 'cell_size': 'foobar'}
+        ]
+    }
+    cell.custom_schema = {'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'value'}]}
+    assert cell.get_custom_schema() == {
+        'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'text'}]
+    }
+    cell.custom_schema = {'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'title'}]}
+    assert cell.get_custom_schema() == {
+        'cells': [{'varname': '@custom@', 'template': 'foobar', 'display_mode': 'title'}]
+    }
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_save_cache(mock_send):
+    page = Page.objects.create(title='xxx', slug='test_card_cell_save_cache', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    assert cell.get_additional_label() is None
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    assert cell.cached_title == 'Card Model 1'
+    assert cell.cached_json != {}
+    assert cell.get_additional_label() == 'Card Model 1'
+    # make sure cached attributes are removed from serialized pages
+    assert 'cached_' not in json.dumps(page.get_serialized_page())
+
+    # artificially change title and json
+    WcsCardInfosCell.objects.filter(pk=cell.pk).update(cached_title='XXX', cached_json={})
+    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_title == 'XXX'
+    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_json == {}
+    # run update db cache
+    appconfig = apps.get_app_config('wcs')
+    appconfig.update_db_cache()
+    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_title == 'Card Model 1'
+    assert WcsCardInfosCell.objects.get(pk=cell.pk).cached_json != {}
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_validity(mock_send):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=0)
+    validity_info = ValidityInfo.objects.latest('pk')
+    assert validity_info.invalid_reason_code == 'wcs_card_not_defined'
+    assert validity_info.invalid_since is not None
+
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    assert ValidityInfo.objects.exists() is False
+
+    # can not retrieve data, don't set cell as invalid
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.status_code = 500
+        requests_get.return_value = mock_resp
+        cell.save()
+    assert ValidityInfo.objects.exists() is False
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        requests_get.side_effect = ConnectionError()
+        cell.save()
+    assert ValidityInfo.objects.exists() is False
+
+    # can not retrieve carddefs, don't set cell as invalid
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.status_code = 404
+        requests_get.return_value = mock_resp
+        cell.save()
+    assert ValidityInfo.objects.exists() is False
+
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.json = lambda *a, **k: {'err': 1, 'err_class': 'Page not found'}
+        mock_resp.status_code = 404
+        requests_get.return_value = mock_resp
+        cell.carddef_reference = 'default:foobar'
+        cell.save()
+    validity_info = ValidityInfo.objects.latest('pk')
+    assert validity_info.invalid_reason_code == 'wcs_card_not_found'
+    assert validity_info.invalid_since is not None
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_check_validity(mock_send):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(
+        page=page,
+        placeholder='content',
+        order=0,
+        carddef_reference='default:card_a',
+        card_ids='1',
+    )
+    cell2 = WcsCardInfosCell.objects.create(
+        page=page, placeholder='content', order=1, carddef_reference='default:card_b'
+    )
+
+    # no related_card_path
+    cell2.check_validity()
+    assert ValidityInfo.objects.exists() is False
+
+    # correct related_card_path but sluga is not defined
+    cell2.related_card_path = 'sluga/cardb'
+    cell2.save()
+    cell2.check_validity()
+    validity_info = ValidityInfo.objects.latest('pk')
+    assert validity_info.invalid_reason_code == 'wcs_card_relation_not_found'
+    assert validity_info.invalid_since is not None
+
+    # sluga is now defined
+    cell.slug = 'sluga'
+    cell.save()
+    cell2.check_validity()
+    assert ValidityInfo.objects.exists() is False
+
+    # bad related_card_path
+    cell2.related_card_path = 'sluga/foobar'
+    cell2.save()
+    cell2.check_validity()
+    validity_info = ValidityInfo.objects.latest('pk')
+    assert validity_info.invalid_reason_code == 'wcs_card_relation_not_found'
+    assert validity_info.invalid_since is not None
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_manager_card_cell(mock_send, app, admin_user):
+    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard', sub_slug='foobar')
+    cell = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=0)
+
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert 'application/json' not in resp
+
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert '<script id="cell-%s-card-schema-default:card_model_1" type="application/json">' % cell.pk in resp
+
+    assert ('data-cell-reference="%s"' % cell.get_reference()) in resp.text
+    assert cell.without_user is False
+    assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value == 'on'
+    resp.forms[0]['c%s-with_user' % cell.get_reference()].value = False
+    manager_submit_cell(resp.forms[0])
+    cell.refresh_from_db()
+    assert cell.without_user is True
+    assert resp.forms[0]['c%s-with_user' % cell.get_reference()].value is None
+
+    # card with relations
+    cell.carddef_reference = 'default:card_a'
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    # but only one cell on the page, no relations to follow
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+
+    # all cards
+    cell.related_card_path = '__all__'
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', False, 'Identifier from page URL'),
+        ('__all__', True, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+
+    # add a second cell, related to the first card model
+    cell.related_card_path = ''
+    cell.save()
+    cell2 = WcsCardInfosCell.objects.create(
+        page=page, placeholder='content', order=1, carddef_reference='default:card_b'
+    )
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    # still no relation to follow
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+    # no cell with id and slug
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+
+    # set a slug on first cell
+    cell.slug = 'sluga'
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    # still no relation to follow
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+    # multiple relations to follow
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('sluga/cardb', False, 'sluga/cardb'),
+        ('sluga/cardsb', False, 'sluga/cardsb'),
+        ('sluga/blockb_cardb', False, 'sluga/blockb_cardb'),
+        ('sluga/cardc/cardb', False, 'sluga/cardc/cardb'),
+        ('sluga/cardc/cardsb', False, 'sluga/cardc/cardsb'),
+        ('sluga/cardc/blockb_cardb', False, 'sluga/cardc/blockb_cardb'),
+    ]
+
+    # set a list of ids on first cell
+    cell.card_ids = '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}'
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    # still no relation to follow
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', False, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', True, 'Other Card Identifiers'),
+    ]
+    # can not user cell with multiple ids as reference
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+
+    # define a slug on second cell
+    cell.card_ids = ''
+    cell.save()
+    cell2.slug = 'slugb'
+    cell2.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    # multiple relations to follow
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('slugb/reverse:cardb', False, 'slugb/cardb (reverse)'),
+        ('slugb/reverse:cardsb', False, 'slugb/cardsb (reverse)'),
+        ('slugb/reverse:blockb_cardb', False, 'slugb/blockb_cardb (reverse)'),
+    ]
+    # still multiple relations to follow
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('sluga/cardb', False, 'sluga/cardb'),
+        ('sluga/cardsb', False, 'sluga/cardsb'),
+        ('sluga/blockb_cardb', False, 'sluga/blockb_cardb'),
+        ('sluga/cardc/cardb', False, 'sluga/cardc/cardb'),
+        ('sluga/cardc/cardsb', False, 'sluga/cardc/cardsb'),
+        ('sluga/cardc/blockb_cardb', False, 'sluga/cardc/blockb_cardb'),
+    ]
+
+    # set a related_path on cell2
+    resp.forms[1]['c%s-related_card_path' % cell2.get_reference()] = 'sluga/cardb'
+    resp.forms[1]['c%s-card_ids' % cell2.get_reference()] = 'foobar'
+    resp = resp.forms[1].submit()
+    cell2.refresh_from_db()
+    assert cell2.related_card_path == 'sluga/cardb'
+    assert cell2.card_ids == ''
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    # no more relation to follow
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+    ]
+    # still multiple relations to follow
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', False, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('sluga/cardb', True, 'sluga/cardb'),
+        ('sluga/cardsb', False, 'sluga/cardsb'),
+        ('sluga/blockb_cardb', False, 'sluga/blockb_cardb'),
+        ('sluga/cardc/cardb', False, 'sluga/cardc/cardb'),
+        ('sluga/cardc/cardsb', False, 'sluga/cardc/cardsb'),
+        ('sluga/cardc/blockb_cardb', False, 'sluga/cardc/blockb_cardb'),
+    ]
+    resp.forms[1].submit()
+    cell2.refresh_from_db()
+    assert cell2.related_card_path == 'sluga/cardb'
+    assert cell2.card_ids == ''
+
+    # check circular relations
+    cell.slug = 'sluge'
+    cell.carddef_reference = 'default:card_e'
+    cell.save()
+    cell2.carddef_reference = 'default:card_d'
+    cell2.slug = 'slugd'
+    cell2.related_card_path = ''
+    cell2.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('slugd/cardd-foo/carde-foo', False, 'slugd/cardd-foo/carde-foo'),
+        ('slugd/carde-foo', False, 'slugd/carde-foo'),
+    ]
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('sluge/cardd-bar', False, 'sluge/cardd-bar'),
+        ('sluge/reverse:carde-foo', False, 'sluge/carde-foo (reverse)'),
+    ]
+
+    cell.slug = 'slugd'
+    cell.carddef_reference = 'default:card_d'
+    cell.save()
+    cell2.carddef_reference = 'default:card_d'
+    cell2.slug = 'slugd-bis'
+    cell2.related_card_path = ''
+    cell2.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('slugd-bis/cardd-foo', False, 'slugd-bis/cardd-foo'),
+        ('slugd-bis/reverse:cardd-foo', False, 'slugd-bis/cardd-foo (reverse)'),
+        ('slugd-bis/carde-foo/cardd-bar', False, 'slugd-bis/carde-foo/cardd-bar'),
+        ('slugd-bis/carde-foo/reverse:carde-foo', False, 'slugd-bis/carde-foo/carde-foo (reverse)'),
+    ]
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('slugd/cardd-foo', False, 'slugd/cardd-foo'),
+        ('slugd/reverse:cardd-foo', False, 'slugd/cardd-foo (reverse)'),
+        ('slugd/carde-foo/cardd-bar', False, 'slugd/carde-foo/cardd-bar'),
+        ('slugd/carde-foo/reverse:carde-foo', False, 'slugd/carde-foo/carde-foo (reverse)'),
+    ]
+
+    cell.slug = 'sluge'
+    cell.carddef_reference = 'default:card_e'
+    cell.save()
+    cell2.carddef_reference = 'default:card_e'
+    cell2.slug = 'sluge-bis'
+    cell2.related_card_path = ''
+    cell2.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.forms[0]['c%s-related_card_path' % cell.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('sluge-bis/cardd-bar/carde-foo', False, 'sluge-bis/cardd-bar/carde-foo'),
+    ]
+    assert resp.forms[1]['c%s-related_card_path' % cell2.get_reference()].options == [
+        ('--', True, 'Identifier from page URL'),
+        ('__all__', False, 'All cards'),
+        ('', False, 'Other Card Identifiers'),
+        ('sluge/cardd-bar/carde-foo', False, 'sluge/cardd-bar/carde-foo'),
+    ]
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_manager_card_cell_tabs(mock_send, app, admin_user):
+    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard', sub_slug='foobar')
+    cell = WcsCardInfosCell.objects.create(page=page, placeholder='content', order=0)
+
+    app = login(app)
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert not resp.pyquery('[data-tab-slug="general"] select[name$="title_type"]')
+    assert not resp.pyquery('[data-tab-slug="general"] input[name$="custom_title"]')
+    assert not resp.pyquery('[data-tab-slug="general"] input[name$="limit"]')
+    assert not resp.pyquery('#tab-%s-general.pk-tabs--button-marker' % cell.get_reference())
+    assert resp.pyquery('[data-tab-slug="appearance"] select[name$="title_type"]')
+    assert resp.pyquery('[data-tab-slug="appearance"] input[name$="custom_title"]')
+    assert not resp.pyquery('[data-tab-slug="appearance"] input[name$="customize_display"]')
+    assert resp.pyquery('[data-tab-slug="display"] input[name$="limit"]')
+
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    resp = app.get('/manage/pages/%s/' % page.pk)
+    assert resp.pyquery('#tab-%s-general.pk-tabs--button-marker' % cell.get_reference())
+    assert resp.pyquery('[data-tab-slug="display"] input[name$="customize_display"]')
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_load(mock_send):
+    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    site_export = [page.get_serialized_page()]
+    cell.delete()
+    assert not Page.objects.get(pk=page.pk).get_cells()
+    Page.load_serialized_pages(site_export)
+    page = Page.objects.get(slug='test_cards')
+    cells = page.get_cells()
+    assert len(cells) == 1
+    cell = cells[0]
+    assert cell.cached_title == 'Card Model 1'
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render(mock_send, context, app):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.title_type = 'manual'
+    cell.custom_title = 'Foo bar {{ card.fields.title }}'
+    cell.save()
+
+    # carddef_reference is not defined
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+    assert '<h2>Card Model 1</h2>' not in result
+    assert '<p>Unknown Card</p>' in result
+
+    # card id not in context
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+    del context['card_model_1_id']
+    assert 'card_model_1_id' not in context
+    result = cell.render(context)
+    assert '<h2>Card Model 1</h2>' in result  # default value
+    assert '<p>Unknown Card</p>' in result
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.status_code = 500
+        requests_get.return_value = mock_resp
+        result = cell.render(context)
+    assert '<h2>Card Model 1</h2>' in result  # default value
+    assert '<p>Unknown Card</p>' in result
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        requests_get.side_effect = ConnectionError()
+        result = cell.render(context)
+    assert '<h2>Card Model 1</h2>' in result  # default value
+    assert '<p>Unknown Card</p>' in result
+
+    context.pop('title')
+    cell.title_type = 'auto'
+    cell.save()
+    mock_send.reset_mock()
+    result = cell.render(context)
+    assert '<h2>Card Model 1 - aa</h2>' in result
+    assert PyQuery(result).find('.label:contains("Field A") + .value').text() == '<i>a</i>'
+    assert PyQuery(result).find('.label:contains("Field B") + .value').text() == 'yes'
+    assert PyQuery(result).find('.label:contains("Field C") + .value').text() == 'Sept. 28, 2020'
+    assert PyQuery(result).find('.label:contains("Related") + .value').text() == 'Foo Bar'
+    assert 'related_raw' not in result
+    assert 'related_structured' not in result
+    assert PyQuery(result).find('.label:contains("Field D") + .value a').text() == 'file.pdf'
+
+    context.pop('title')
+    cell.title_type = 'manual'
+    cell.custom_title = '<b>Foo bar {{ card.fields.fielda }}</b>'
+    cell.save()
+    assert cell.get_additional_label() == '&lt;b&gt;Foo bar {{ card.fields.fielda }}&lt;/b&gt;'
+    result = cell.render(context)
+    assert '<h2>&lt;b&gt;Foo bar &lt;i&gt;a&lt;/i&gt;&lt;/b&gt;</h2>' in result
+
+    context.pop('title')
+    cell.custom_title = '{{ foobar }}'
+    cell.save()
+    result = cell.render(context)
+    assert '<h2>Card Model 1 - aa</h2>' in result  # empty value from template, default value
+
+    context.pop('title')
+    cell.custom_title = '{% if %}'
+    cell.save()
+    result = cell.render(context)
+    assert '<h2>Card Model 1 - aa</h2>' in result  # template error, default value
+
+    context.pop('title')
+    cell.title_type = 'empty'
+    cell.save()
+    result = cell.render(context)
+    assert '<h2>' not in result
+
+    # test available context
+    cell.title_type = 'manual'
+    cell.custom_title = 'X{{ site_base }}Y'
+    cell.card_ids = '11'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert '<h2>Xhttp://testserverY</h2>' in cell_resp
+
+    cell.card_ids = '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}'
+    cell.title_type = 'manual'
+    cell.custom_title = 'Foo bar X{{ repeat_index }}Y'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 3
+
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    for i in range(0, 3):
+        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
+        assert '<h2>Foo bar X%sY</h2>' % i in cell_resp
+    # again, without ajax: urls are already in cache
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 3
+    for i in range(0, 3):
+        assert '<h2>Foo bar X%sY</h2>' % i in resp
+    assert 'data-paginate-by="10"' in resp
+
+    cell.limit = 42
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 3
+    for i in range(0, 3):
+        assert '<h2>Foo bar X%sY</h2>' % i in resp
+    assert 'data-paginate-by="42"' in resp
+
+    # using custom view
+    cell.carddef_reference = 'default:card_model_1:foo'
+    cell.save()
+    result = cell.render(context)
+    assert 'Foo bar X0Y' in result
+
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_resp = Response()
+        mock_resp.status_code = 404
+        requests_get.return_value = mock_resp
+        result = cell.render(context)
+
+    # nothing, hide cell
+    assert not result.strip()
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_text_field(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+
+    # field E is split in paragraphs
+    assert (
+        PyQuery(result).find('.label:contains("Field E") + .value p:first-child').text().strip()
+        == 'lorem<strong>ipsum'
+    )
+    assert (
+        PyQuery(result).find('.label:contains("Field E") + .value p:last-child').text().strip()
+        == "hello'world"
+    )
+
+    # field F is put in a <pre>
+    assert (
+        PyQuery(result).find('.label:contains("Field F") + .value pre').text()
+        == 'lorem<strong>ipsum hello world'
+    )
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_email_field(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = 'default:card_model_1'
+    cell.save()
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+
+    assert PyQuery(result).find('.label:contains("Field G") + .value a').text() == 'test@localhost'
+
+    assert (
+        PyQuery(result).find('.label:contains("Field G") + .value a').attr['href'] == 'mailto:test@localhost'
+    )
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_string_with_url_field(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = 'default:card_model_1'
+    cell.custom_title = 'Foo bar {{ card.fields.title }}'
+    cell.save()
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+
+    assert PyQuery(result).find('.label:contains("Field H") + .value a').text() == 'https://www.example.net/'
+
+    assert (
+        PyQuery(result).find('.label:contains("Field H") + .value a').attr['href']
+        == 'https://www.example.net/'
+    )
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_custom_schema_card_field(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(
+        page=page,
+        placeholder='content',
+        order=0,
+        carddef_reference='default:card_model_1',
+        custom_schema={'cells': [{'varname': 'fielda', 'field_content': 'value', 'display_mode': 'title'}]},
+    )
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+    assert PyQuery(result).find('h3').text() == '<i>a</i>'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fielda',
+        'field_content': 'value',
+        'display_mode': 'subtitle',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('h4').text() == '<i>a</i>'
+
+    cell.custom_schema['cells'][0] = {'varname': 'fielda', 'field_content': 'label', 'display_mode': 'title'}
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('h3').text() == 'Field A'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fielda',
+        'field_content': 'label',
+        'display_mode': 'subtitle',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('h4').text() == 'Field A'
+
+    cell.custom_schema['cells'][0] = {'varname': 'fielda', 'field_content': 'label', 'display_mode': 'text'}
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field A'
+
+    cell.custom_schema['cells'][0] = {'varname': 'fielda', 'field_content': 'value', 'display_mode': 'text'}
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.value').text() == '<i>a</i>'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fielda',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field A'
+    assert PyQuery(result).find('.value').text() == '<i>a</i>'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fieldb',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field B'
+    assert PyQuery(result).find('.value').text() == 'yes'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fieldc',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field C'
+    assert PyQuery(result).find('.value').text() == 'Sept. 28, 2020'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'related',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Related'
+    assert PyQuery(result).find('.value').text() == 'Foo Bar'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fieldd',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field D'
+    assert PyQuery(result).find('.value').text() == 'file.pdf'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fielde',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    # check multiline text field is rendered with multiple paragraphs
+    # (first line "lorem<strong>ipsum" and last line ("hello'world")
+    # and the content is kept properly escaped.
+    assert PyQuery(result).find('.label').text() == 'Field E'
+    assert PyQuery(result).find('.value p:first-child').text().strip() == 'lorem<strong>ipsum'
+    assert PyQuery(result).find('.value p:last-child').text().strip() == "hello'world"
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fieldf',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field F'
+    assert PyQuery(result).find('.value pre').text() == 'lorem<strong>ipsum hello world'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fieldg',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field G'
+    assert PyQuery(result).find('.value a').text() == 'test@localhost'
+    assert PyQuery(result).find('.value a').attr['href'] == 'mailto:test@localhost'
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'fieldh',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Field H'
+    assert PyQuery(result).find('.value a').text() == 'https://www.example.net/'
+    assert PyQuery(result).find('.value a').attr['href'] == 'https://www.example.net/'
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_custom_schema_card_empty_field(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(
+        page=page,
+        placeholder='content',
+        order=0,
+        carddef_reference='default:card_model_1',
+        custom_schema={
+            'cells': [
+                {
+                    'varname': 'empty',
+                    'field_content': 'label-and-value',
+                    'display_mode': 'text',
+                    'empty_value': '@skip@',
+                }
+            ]
+        },
+    )
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+    assert len(PyQuery(result).find('.cell--body > div > div')) == 0
+    assert PyQuery(result).find('.label') == []
+    assert PyQuery(result).find('.value') == []
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'empty',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+        'empty_value': '@empty@',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert len(PyQuery(result).find('.cell--body > div > div')) == 1
+    assert PyQuery(result).find('.label').text() == 'Empty'
+    assert PyQuery(result).find('.value').text() == ''
+
+    cell.custom_schema['cells'][0] = {
+        'varname': 'empty',
+        'field_content': 'label-and-value',
+        'display_mode': 'text',
+        'empty_value': 'Custom text',
+    }
+    cell.save()
+    result = cell.render(context)
+    assert len(PyQuery(result).find('.cell--body > div > div')) == 1
+    assert PyQuery(result).find('.label').text() == 'Empty'
+    assert PyQuery(result).find('.value').text() == 'Custom text'
+
+    for field_content in ['label', 'value']:
+        for display_mode in ['text', 'title', 'subtitle']:
+            if display_mode == 'title':
+                html_tag = 'h3'
+            elif display_mode == 'subtitle':
+                html_tag = 'h4'
+            elif display_mode == 'text' and field_content == 'label':
+                html_tag = '.label'
+            elif display_mode == 'text' and field_content == 'value':
+                html_tag = '.value'
+            cell.custom_schema['cells'][0] = {
+                'varname': 'empty',
+                'field_content': field_content,
+                'display_mode': display_mode,
+                'empty_value': '@skip@',
+            }
+            cell.save()
+            result = cell.render(context)
+            assert len(PyQuery(result).find('.cell--body > div > div')) == 0
+            assert PyQuery(result).find(html_tag) == []
+
+            cell.custom_schema['cells'][0] = {
+                'varname': 'empty',
+                'field_content': field_content,
+                'display_mode': display_mode,
+                'empty_value': '@empty@',
+            }
+            cell.save()
+            result = cell.render(context)
+            assert len(PyQuery(result).find('.cell--body > div > div')) == 1
+            assert PyQuery(result).find(html_tag).text() == ('Empty' if field_content == 'label' else '')
+
+            cell.custom_schema['cells'][0] = {
+                'varname': 'empty',
+                'field_content': field_content,
+                'display_mode': display_mode,
+                'empty_value': 'Custom text',
+            }
+            cell.save()
+            result = cell.render(context)
+            assert len(PyQuery(result).find('.cell--body > div > div')) == 1
+            assert PyQuery(result).find(html_tag).text() == (
+                'Empty' if field_content == 'label' else 'Custom text'
+            )
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_custom_schema_custom_entry(mock_send, context, app):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(
+        page=page,
+        placeholder='content',
+        order=0,
+        carddef_reference='default:card_model_1',
+        custom_schema={
+            'cells': [
+                {
+                    'varname': '@custom@',
+                    'template': "<b>Foo</b> bar'baz {{ card.fields.fielde }}",
+                    'display_mode': 'title',
+                },
+            ]
+        },
+    )
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+    assert '&lt;b&gt;Foo&lt;/b&gt;' in result
+    assert PyQuery(result).find('h3').text() == "<b>Foo</b> bar'baz lorem<strong>ipsum hello'world"
+
+    # test context
+    cell.custom_schema['cells'][0][
+        'template'
+    ] = '{{ card.fields.fielda }} - {{ card.fields.related }} ({{ card.fields.related_structured.id }})'
+    cell.custom_schema['cells'][0]['display_mode'] = 'subtitle'
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('h4').text() == '<i>a</i> - Foo Bar (42)'
+
+    # test display_mode & filters in template
+    cell.custom_schema = {
+        'cells': [
+            {'varname': '@custom@', 'template': 'Foo bar baz', 'display_mode': 'label'},
+            {
+                'varname': '@custom@',
+                'template': '{{ card.fields.related|split:" "|join:"," }}',
+                'display_mode': 'text',
+            },
+        ]
+    }
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.label').text() == 'Foo bar baz'
+    assert PyQuery(result).find('.value').text() == 'Foo,Bar'
+
+    # test available context
+    cell.card_ids = '11'
+    cell.custom_schema = {
+        'cells': [
+            {
+                'varname': '@custom@',
+                'template': 'Foo bar baz {% make_public_url url="http://127.0.0.1:8999/" %}',
+                'display_mode': 'label',
+            },
+        ]
+    }
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert '/api/wcs/file/' in PyQuery(cell_resp.text).find('.label').text()
+
+    cell.custom_schema = {
+        'cells': [
+            {'varname': '@custom@', 'template': 'Foo bar baz X{{ site_base }}Y', 'display_mode': 'label'},
+        ]
+    }
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert PyQuery(cell_resp.text).find('.label').text() == 'Foo bar baz Xhttp://testserverY'
+
+    cell.card_ids = '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}'
+    cell.custom_schema = {
+        'cells': [
+            {'varname': '@custom@', 'template': 'Foo bar baz X{{ repeat_index }}Y', 'display_mode': 'label'},
+        ]
+    }
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 3
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+    for i in range(0, 3):
+        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
+        assert PyQuery(cell_resp.text).find('.label').text() == 'Foo bar baz X%sY' % i
+
+    # custom schema but empty
+    cell.custom_schema = {'cells': []}
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('div.cell--body') == []
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_custom_schema_link_entry(mock_send, context, app):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell.objects.create(
+        page=page,
+        placeholder='content',
+        order=0,
+        carddef_reference='default:card_model_1',
+        custom_schema={
+            'cells': [
+                {
+                    'varname': '@link@',
+                    'url_template': '/foo/bar/{{ card.fields.related_structured.id }}/',
+                    'template': '{{ card.fields.fielda }} - {{ card.fields.related }}',
+                    'display_mode': 'link',
+                },
+            ]
+        },
+    )
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+    assert PyQuery(result).find('.value a').text() == '<i>a</i> - Foo Bar'
+    assert PyQuery(result).find('.value a').attr['href'] == '/foo/bar/42/'
+    assert PyQuery(result).find('.value a').attr['class'] is None
+
+    cell.custom_schema['cells'][0]['display_mode'] = 'button'
+    cell.save()
+    result = cell.render(context)
+    assert PyQuery(result).find('.value a').text() == '<i>a</i> - Foo Bar'
+    assert PyQuery(result).find('.value a').attr['href'] == '/foo/bar/42/'
+    assert PyQuery(result).find('.value a').attr['class'] == 'pk-button'
+
+    cell.custom_schema['cells'][0][
+        'url_template'
+    ] = '{{ site_base }}/foo/bar/{{ card.fields.related_structured.id }}/'
+    cell.custom_schema['cells'][0]['template'] = '<b>{{ card.fields.fielda }}</b> - {{ card.fields.related }}'
+    cell.card_ids = '11'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert (
+        '<div class="value"><a href="http://testserver/foo/bar/42/" class="pk-button">&lt;b&gt;&lt;i&gt;a&lt;/i&gt;&lt;/b&gt; - Foo Bar</a></div>'
+        in cell_resp
+    )
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_all_cards(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,
+        carddef_reference='default:card_model_1',
+        related_card_path='__all__',
+    )
+
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+
+    # check url called
+    mock_send.reset_mock()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 3
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    for i in range(0, 3):
+        assert resp.context['cells'][i].pk == cell.pk
+        assert resp.context['cells'][i].repeat_index == i
+        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
+        assert cell_resp.context['repeat_index'] == i
+    assert len(mock_send.call_args_list) == 7
+    # page rendering
+    assert '/api/cards/card_model_1/list' in mock_send.call_args_list[0][0][0].url
+    # cell rendering
+    for i in range(0, 3):
+        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 1][0][0].url
+        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 2][0][0].url
+        assert 'filter-internal-id' not in mock_send.call_args_list[i * 2 + 2][0][0].url
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_identifier(mock_send, nocache, app):
+    page = Page.objects.create(
+        title='xxx', slug='foo', template_name='standard', sub_slug='(?P<card_model_1_id>[a-z0-9]+)'
+    )
+    cell = WcsCardInfosCell.objects.create(
+        page=page, placeholder='content', order=0, carddef_reference='default:card_model_1'
+    )
+
+    cell_url = reverse(
+        'combo-public-ajax-page-cell',
+        kwargs={'page_pk': page.pk, 'cell_reference': cell.get_reference()},
+    )
+
+    # check url called
+    mock_send.reset_mock()
+    resp = app.get(page.get_online_url() + '11/')
+    assert len(resp.context['cells']) == 1
+    assert resp.context['cells'][0].pk == cell.pk
+    assert resp.context['cells'][0].repeat_index == 0
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert cell_resp.context['repeat_index'] == 0
+    assert 'Card Model 1' in cell_resp
+    assert '<p>Unknown Card</p>' not in cell_resp
+    assert len(mock_send.call_args_list) == 1
+    assert '/api/cards/card_model_1/11/' in mock_send.call_args_list[0][0][0].url
+
+    # with identifiers
+    page.sub_slug = ''
+    page.save()
+    cell.card_ids = '42'
+    cell.save()
+    mock_send.reset_mock()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    assert resp.context['cells'][0].pk == cell.pk
+    assert resp.context['cells'][0].repeat_index == 0
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert cell_resp.context['repeat_index'] == 0
+    assert 'Card Model 1' in cell_resp
+    assert '<p>Unknown Card</p>' in cell_resp
+    assert len(mock_send.call_args_list) == 1
+    assert '/api/cards/card_model_1/42/' in mock_send.call_args_list[0][0][0].url
+
+    cell.card_ids = '42, , 35'
+    cell.save()
+    mock_send.reset_mock()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 2
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    for i in range(0, 2):
+        assert resp.context['cells'][i].pk == cell.pk
+        assert resp.context['cells'][i].repeat_index == i
+        cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
+        assert cell_resp.context['repeat_index'] == i
+        assert 'Card Model 1' in cell_resp
+        assert '<p>Unknown Card</p>' in cell_resp
+    assert len(mock_send.call_args_list) == 2
+    for i in range(0, 2):
+        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i][0][0].url
+        assert '&filter-internal-id=42&filter-internal-id=35&' in mock_send.call_args_list[i][0][0].url
+
+    cell.card_ids = '{% cards|objects:"card_model_1"|last|get:"id" %}'  # syntax error
+    cell.save()
+    mock_send.reset_mock()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert cell_resp.text.replace('\n', '') == ''  # empty-cell
+
+    cell.card_ids = '{{ cards|objects:"card_model_1"|last|get:"id" }}'
+    cell.save()
+    mock_send.reset_mock()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    assert resp.context['cells'][0].pk == cell.pk
+    assert resp.context['cells'][0].repeat_index == 0
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert cell_resp.context['repeat_index'] == 0
+    assert len(mock_send.call_args_list) == 3
+    # page rendering
+    assert '/api/cards/card_model_1/list' in mock_send.call_args_list[0][0][0].url
+    # cell rendering
+    assert '/api/cards/card_model_1/list' in mock_send.call_args_list[1][0][0].url
+    assert '/api/cards/card_model_1/13' in mock_send.call_args_list[2][0][0].url
+
+    def test_card_ids():
+        mock_send.reset_mock()
+        resp = app.get(page.get_online_url())
+        assert len(resp.context['cells']) == 3
+        extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+        for i in range(0, 3):
+            assert resp.context['cells'][i].pk == cell.pk
+            assert resp.context['cells'][i].repeat_index == i
+            cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[i])
+            assert cell_resp.context['repeat_index'] == i
+        assert len(mock_send.call_args_list) == 7
+        # page rendering
+        assert '/api/cards/card_model_1/list' in mock_send.call_args_list[0][0][0].url
+        # cell rendering
+        card_ids = [c['id'] for c in WCS_CARDS_DATA['card_model_1']]
+        filters = '&%s&' % '&'.join('filter-internal-id=%s' % cid for cid in card_ids)
+        for i in range(0, 3):
+            assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 1][0][0].url
+            assert '/api/cards/card_model_1/list' in mock_send.call_args_list[i * 2 + 2][0][0].url
+            assert filters in mock_send.call_args_list[i * 2 + 2][0][0].url
+
+    for card_ids in [
+        '{% for card in cards|objects:"card_model_1" %}{{ card.id }},{% endfor %}',
+        '{{ cards|objects:"card_model_1"|getlist:"id"|join:"," }}',
+    ]:
+        cell.card_ids = card_ids
+        cell.save()
+        test_card_ids()
+
+        cell.card_ids = '{{ var1 }}'
+        cell.save()
+        page.extra_variables = {'var1': card_ids}
+        page.save()
+        test_card_ids()
+        page.extra_variables = {}
+        page.save()
+
+    # with a card_ids template, but result is empty
+    cell.card_ids = '{{ foo }}'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    cell_resp = app.get(cell_url + '?ctx=' + extra_ctx[0])
+    assert cell_resp.text.replace('\n', '') == ''  # empty-cell
+
+
+@mock.patch('requests.Session.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_parts in enumerate(urls):
+            if not isinstance(url_parts, tuple):
+                url_parts = (url_parts,)
+            for url_part in url_parts:
+                assert url_part 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']) >= 2
+        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_parts in enumerate(urls):
+                if not isinstance(url_parts, tuple):
+                    url_parts = (url_parts,)
+                for url_part in url_parts:
+                    assert url_part 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 = TextCell.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/',  # check user access
+            '/api/cards/card_b/1/',  # get card
+        ]
+    )
+    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/',  # check user access
+            '/api/cards/card_b/7/',  # get card
+        ]
+    )
+
+    # change cell ordering - cell with slug is after cell with related
+    cell.order = 42
+    cell.save()
+    # no error, but it does not work as expected: both cells has slug.
+    app.get(page.get_online_url(), status=200)
+    # remove slug of second cell
+    cell2.slug = ''
+    cell2.save()
+    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/',  # check user access
+        '/api/cards/card_b/7/',  # get card
+    ]
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 2
+    assert resp.context['cells'][0].pk == cell2.pk
+    assert resp.context['cells'][0].repeat_index == 0
+    assert resp.context['cells'][1].pk == cell.pk
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    mock_send.reset_mock()
+    cell_resp = app.get(cell2_url + '?ctx=' + extra_ctx[0])
+    assert cell_resp.context['repeat_index'] == 0
+    assert len(mock_send.call_args_list) == len(urls)
+    for j, url_parts in enumerate(urls):
+        if not isinstance(url_parts, tuple):
+            url_parts = (url_parts,)
+        for url_part in url_parts:
+            assert url_part in mock_send.call_args_list[j][0][0].url
+
+    # reset
+    cell.order = 0  # reset
+    cell.save()
+    cell2.slug = 'slugb'
+    cell2.save()
+
+    # test with custom_view
+    cell2.carddef_reference = 'default:card_b:a-custom-view'
+    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/a-custom-view/7/',  # check user access
+            '/api/cards/card_b/a-custom-view/7/',  # get card
+        ]
+    )
+
+    # direct and multiple relation (items)
+    cell2.carddef_reference = 'default:card_b'  # reset
+    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/list', '&filter-internal-id=2&filter-internal-id=3&'),  # check user access
+            ('/api/cards/card_b/list', '&filter-internal-id=2&filter-internal-id=3&'),  # get card
+        ]
+    )
+
+    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/list', '&filter-internal-id=8&filter-internal-id=9&'),  # check user access
+            ('/api/cards/card_b/list', '&filter-internal-id=8&filter-internal-id=9&'),  # get card
+        ]
+    )
+
+    # 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/list', '&filter-internal-id=4&filter-internal-id=5&'),  # check user access
+            ('/api/cards/card_b/list', '&filter-internal-id=4&filter-internal-id=5&'),  # get card
+        ]
+    )
+
+    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/list', '&filter-internal-id=10&filter-internal-id=11&'),  # check user access
+            ('/api/cards/card_b/list', '&filter-internal-id=10&filter-internal-id=11&'),  # get card
+        ]
+    )
+
+    # 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
+            # check user access
+            (
+                '/api/cards/card_a/list',
+                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
+            ),
+            # get card
+            (
+                '/api/cards/card_a/list',
+                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
+            ),
+        ]
+    )
+
+    # 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
+            # check user access
+            (
+                '/api/cards/card_a/list',
+                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
+            ),
+            # get card
+            (
+                '/api/cards/card_a/list',
+                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
+            ),
+        ]
+    )
+
+    # 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
+            # check user access
+            (
+                '/api/cards/card_a/list',
+                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
+            ),
+            # get card
+            (
+                '/api/cards/card_a/list',
+                '&filter-internal-id=1&filter-internal-id=2&filter-internal-id=3&filter-internal-id=4&',
+            ),
+        ]
+    )
+
+    # 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/',
+        ]
+    )
+
+    # reverse relation with many models using the same varname
+    cell.carddef_reference = 'default:card_h'
+    cell.slug = 'slugh'
+    cell.card_ids = '42'
+    cell.related_card_path = ''
+    cell.save()
+    cell2.carddef_reference = 'default:card_f'
+    cell2.slug = 'slugf'
+    cell2.card_ids = ''
+    cell2.related_card_path = 'slugh/reverse:cardh'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_h/42/',
+            # get list of card_f with cardf=42
+            '/api/cards/card_f/list?orig=combo&filter-cardh=42',
+            # and follow cardf reverse relation
+            '/api/cards/card_f/41/',  # check user access
+            '/api/cards/card_f/41/',  # get card
+        ]
+    )
+
+    cell.card_ids = '44'
+    cell.related_card_path = ''
+    cell.save()
+    cell2.carddef_reference = 'default:card_g'
+    cell2.slug = 'slugg'
+    cell2.card_ids = ''
+    cell2.related_card_path = 'slugh/reverse:cardh'
+    cell2.save()
+    multiple(
+        urls=[
+            # get first cell data
+            '/api/cards/card_h/44/',
+            # get list of card_g with cardf=44
+            '/api/cards/card_g/list?orig=combo&filter-cardh=44',
+            # and follow cardf reverse relation
+            '/api/cards/card_g/43/',  # check user access
+            '/api/cards/card_g/43/',  # get card
+        ]
+    )
+
+
+@pytest.mark.parametrize('carddef_reference', ['default:card_model_1', 'default:card_model_1:foo'])
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_only_for_user(mock_send, context, carddef_reference):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = carddef_reference
+    cell.only_for_user = False
+    cell.save()
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+
+    assert cell.is_visible(request=context['request']) is True
+    context['request'].user = MockUserWithNameId()
+    assert cell.is_visible(request=context['request']) is True
+
+    cell.only_for_user = True
+    cell.save()
+    context['request'].user = None
+    assert cell.is_visible(request=context['request']) is False
+    context['request'].user = MockUserWithNameId()
+    assert cell.is_visible(request=context['request']) is True
+
+    cache.clear()
+    context['synchronous'] = True  # to get fresh content
+    context['request'].user = None
+
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'filter-user-uuid' not in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = MockUser()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'filter-user-uuid' not in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = MockUserWithNameId()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'filter-user-uuid=xyz' in mock_send.call_args_list[0][0][0].url
+
+
+@pytest.mark.parametrize('carddef_reference', ['default:card_model_1', 'default:card_model_1:foo'])
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_cell_render_user(mock_send, context, nocache, carddef_reference):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = carddef_reference
+    cell.save()
+
+    context['card_model_1_id'] = 11
+    request = RequestFactory().get('/')
+    cell.modify_global_context(context, request)
+    cell.repeat_index = 0
+    context['synchronous'] = True  # to get fresh content
+
+    assert context['request'].user is None
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'NameID=&' in mock_send.call_args_list[0][0][0].url
+    assert 'email=&' in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = AnonymousUser()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'NameID=&' in mock_send.call_args_list[0][0][0].url
+    assert 'email=&' in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = MockUser()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'email=foo%40example.net' in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = MockUserWithNameId()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'NameID=xyz' in mock_send.call_args_list[0][0][0].url
+
+    cell.without_user = True
+    cell.save()
+
+    context['request'].user = None
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'NameID' not in mock_send.call_args_list[0][0][0].url
+    assert 'email' not in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = MockUser()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'NameID' not in mock_send.call_args_list[0][0][0].url
+    assert 'email' not in mock_send.call_args_list[0][0][0].url
+
+    context['request'].user = MockUserWithNameId()
+    mock_send.reset_mock()
+    cell.render(context)
+    assert 'NameID' not in mock_send.call_args_list[0][0][0].url
+    assert 'email' not in mock_send.call_args_list[0][0][0].url
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_cell_condition(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,
+        carddef_reference='default:card_model_1',
+        card_ids='{{ cards|objects:"card_model_1"|last|get:"id" }}',
+    )
+
+    cell.condition = 'cards|objects:"card_model_1"|getlist:"id"|list'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+
+    cell.condition = 'cards|objects:"card_model_1"|getlist:"id"|get:42'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context.get('cells') or []) == 0
+
+    page.extra_variables = {'var1': '{{ cards|objects:"card_model_1"|getlist:"id"|list }}'}
+    page.save()
+    cell.condition = 'var1'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+
+    page.extra_variables = {'var1': '{{ cards|objects:"card_model_1"|getlist:"id"|get:42|default:"" }}'}
+    page.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context.get('cells') or []) == 0
+
+    page.extra_variables = {
+        'var1': '{{ cards|objects:"unknown"|first|get:"id"|default:"" }}',
+        'var2': '{{ cards|objects:"card_model_1"|first|get:"id"|default:"" }}',
+    }
+    page.save()
+    cell.condition = 'not var1'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    cell.condition = 'var2'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+    cell.condition = 'not var2'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context.get('cells') or []) == 0
+    cell.condition = 'not var1 and not var2'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context.get('cells') or []) == 0
+    cell.condition = 'not var1 and var2'
+    cell.save()
+    resp = app.get(page.get_online_url())
+    assert len(resp.context['cells']) == 1
+
+
+@mock.patch('requests.Session.send', side_effect=mocked_requests_send)
+def test_card_file_redirection(mock_send, app):
+    page = Page(title='One', slug='one', template_name='standard')
+    page.save()
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = 'default:card_model_1'
+    cell.card_ids = '11'
+    cell.save()
+    resp = app.get('/one/')
+    ajax_cell_url = PyQuery(resp.text).find('[data-ajax-cell-url]').attr['data-ajax-cell-url']
+    extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)
+    resp = app.get(ajax_cell_url + '?ctx=' + extra_ctx[0])
+    file_url = PyQuery(resp.text).find('[download]').attr['href']
+    resp = app.get(file_url)
+    assert 'download?f=42' in resp.location
+    assert '&signature=' in resp.location
+
+    # invalid crypto
+    resp = app.get(file_url[:-2] + 'X/', status=403)
+
+    # invalid session key
+    resp = app.get(file_url.replace('file/', 'file/X'), status=403)
diff --git a/tests/wcs/utils.py b/tests/wcs/utils.py
new file mode 100644
index 00000000..ea34b585
--- /dev/null
+++ b/tests/wcs/utils.py
@@ -0,0 +1,413 @@
+import copy
+import json
+import re
+import urllib.parse
+from unittest import mock
+
+WCS_FORMDEFS_DATA = [
+    {'slug': 'a-private-form', 'title': 'a private form', 'url': '/a-private-form/'},
+    {'slug': 'a-second-form-title', 'title': 'a second form title', 'url': '/a-second-form-title/'},
+    {'slug': 'form-title', 'title': 'form title', 'url': '/form-title/', 'keywords': ['foo', 'bar']},
+    {'slug': 'third-form-title', 'title': 'Third form title', 'url': '/third-form-title/'},
+]
+
+WCS_CATEGORIES_DATA = [
+    {'slug': 'test-%s' % i, 'title': 'Test %s' % i, 'url': '/test-%s/' % i} for i in [3, 9]
+]
+
+WCS_CATEGORIES_FORMDEFS_DATA = [
+    {
+        'slug': 'form-title',
+        'title': 'form title',
+        'url': '/form-title/',
+        'keywords': ['foo', 'bar'],
+        'count': 42,
+    },
+    {
+        'slug': 'a-second-form-title',
+        'title': 'a second form title',
+        'url': '/a-second-form-title/',
+        'count': 35,
+    },
+]
+
+WCS_USER_FORMS_DATA = [
+    {
+        'name': 'name',
+        'title': 'Title',
+        'url': '/form/1/',
+        'form_receipt_datetime': '2015-01-01T00:00:00',
+        'readable': True,
+        'category_slug': 'test-9',
+    },
+    {'name': 'name', 'url': '/form/2/', 'form_receipt_datetime': '2015-01-01T00:00:00', 'readable': True},
+    {'name': 'name', 'title': 'Title', 'url': '/form/3/', 'form_receipt_datetime': '2015-01-01T00:00:00'},
+]
+
+WCS_FORMS_DATA = [
+    {
+        'form_receipt_datetime': '2019-10-17T16:46:03',
+        'form_url': '/foobar/1',
+        'form_url_backoffice': '/backoffice/management/foobar/1/',
+    },
+    {
+        'form_receipt_datetime': '2019-10-17T16:46:04',
+        'form_url': '/foobar/2',
+        'form_url_backoffice': '/backoffice/management/foobar/2/',
+    },
+]
+
+WCS_USER_DRAFTS_DATA = [
+    {
+        'name': 'name',
+        'title': 'Title',
+        'url': '/form/1/',
+        'form_receipt_datetime': '2015-01-01T00:00:00',
+        'category_slug': 'test-9',
+    },
+    {'name': 'name', 'url': '/form/2/', 'form_receipt_datetime': '2015-01-01T00:00:00'},
+    {'name': 'name', 'title': 'Title', 'url': '/form/3/', 'form_receipt_datetime': '2015-01-01T00:00:00'},
+    {
+        'name': 'name',
+        'title': 'Title',
+        'url': '/form/4/',
+        'form_receipt_datetime': '2015-01-01T00:00:00',
+        'form_status_is_endpoint': True,
+        'category_slug': 'test-9',
+    },
+]
+
+WCS_CARDDEFS_DATA = [
+    {'title': 'Card Model 1', 'slug': 'card_model_1', 'custom_views': [{'id': 'foo', 'text': 'bar'}]},
+    {'title': 'Card Model 2', 'slug': 'card_model_2'},
+    {'title': 'Card Model 3', 'slug': 'card_model_3'},
+    {'title': 'Card A', 'slug': 'card_a'},
+    {'title': 'Card B', 'slug': 'card_b', 'custom_views': [{'id': 'a-custom-view', 'text': 'foo bar'}]},
+    {'title': 'Card C', 'slug': 'card_c'},
+    {'title': 'Card D', 'slug': 'card_d'},
+    {'title': 'Card E', 'slug': 'card_e'},
+]
+
+WCS_CARDS_DATA = {
+    'card_model_1': [
+        {
+            'id': 11,
+            'display_id': '10-11',
+            'display_name': 'Card Model 1 - n°10-11',
+            'digest': 'a a a',
+            'text': 'aa',
+            'url': '/backoffice/data/card_model_1/11/',
+            'fields': {
+                'fielda': '<i>a</i>',
+                'fieldb': True,
+                'fieldc': '2020-09-28',
+                'fieldd': {'filename': 'file.pdf', 'url': 'http://127.0.0.1:8999/download?f=42'},
+                'fielde': "lorem<strong>ipsum\n\nhello'world",
+                'fieldf': 'lorem<strong>ipsum\n\nhello world',
+                'fieldg': 'test@localhost',
+                'fieldh': 'https://www.example.net/',
+                'related': 'Foo Bar',
+                'related_raw': 42,
+                'related_structured': {'id': 42, 'text': 'blah'},
+            },
+        },
+        {
+            'id': 12,
+            'display_id': '10-12',
+            'display_name': 'Card Model 1 - n°10-12',
+            'digest': 'b b b',
+            'text': 'bb',
+            'url': '/backoffice/data/card_model_1/12/',
+        },
+        {
+            'id': 13,
+            'display_id': '10-13',
+            'display_name': 'Card Model 1 - n°10-13',
+            'digest': 'c c c',
+            'text': 'cc',
+            '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': {},
+        },
+    ],
+    'card_f': [
+        {'id': 41, 'fields': {'cardh_raw': 42}},
+    ],
+    'card_g': [
+        {'id': 43, 'fields': {'cardh_raw': 44}},
+    ],
+    'card_h': [
+        {'id': 42, 'fields': {}},
+        {'id': 44, 'fields': {}},
+    ],
+}
+
+WCS_CARDS_CUSTOM_VIEW_DATA = [
+    {
+        'id': 11,
+        'display_id': '10-11',
+        'display_name': 'Card Model 1 - n°10-11',
+        'digest': 'a a a',
+        'text': 'aa',
+        'url': '/backoffice/data/card_model_1/11/',
+    },
+    {
+        'id': 12,
+        'display_id': '10-12',
+        'display_name': 'Card Model 1 - n°10-12',
+        'digest': 'b b b',
+        'text': 'bb',
+        'url': '/backoffice/data/card_model_1/12/',
+    },
+]
+
+WCS_CARDDEF_SCHEMAS = {
+    'card_model_1': {
+        'name': 'Card Model 1',
+        'fields': [
+            {'label': 'Field A', 'varname': 'fielda', 'type': 'string'},
+            {'label': 'Field B', 'varname': 'fieldb', 'type': 'bool'},
+            {'label': 'Field C', 'varname': 'fieldc', 'type': 'date'},
+            {'label': 'Field D', 'varname': 'fieldd', 'type': 'file'},
+            {'label': 'Field E', 'varname': 'fielde', 'type': 'text'},
+            {'label': 'Field F', 'varname': 'fieldf', 'type': 'text', 'pre': True},
+            {'label': 'Field G', 'varname': 'fieldg', 'type': 'email'},
+            {'label': 'Field H', 'varname': 'fieldh', 'type': 'string'},
+            {'label': 'Empty', 'varname': 'empty', 'type': 'string'},
+            {'label': 'Related', 'varname': 'related', 'type': 'item'},
+            {'label': 'Page', 'type': 'page'},
+            {'label': 'Comment', 'type': 'comment'},
+            {'label': 'Title', 'type': 'title'},
+            {'label': 'Subtitle', 'type': 'subtitle'},
+            {'label': 'Empty', 'varname': None, 'type': 'string'},
+        ],
+    },
+    'card_a': {
+        'name': 'Card A',
+        'fields': [
+            {'label': 'Card B', 'varname': 'cardb', 'type': 'item'},
+            {'label': 'Cards B', 'varname': 'cardsb', 'type': 'items'},
+            {'label': 'Block B', 'varname': 'blockb', 'type': 'block:b'},
+            {'label': 'Card C', 'varname': 'cardc', 'type': 'item'},
+        ],
+        'relations': [
+            {'obj': 'carddef:card_b', 'varname': 'cardb', 'type': 'item', 'reverse': False},
+            {'obj': 'carddef:card_b', 'varname': 'cardsb', 'type': 'items', 'reverse': False},
+            {'obj': 'carddef:card_b', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': False},
+            {'obj': 'carddef:card_c', 'varname': 'cardc', 'type': 'item', 'reverse': False},
+            {
+                'obj': 'carddef:card_z',  # unknown card model
+                'varname': 'cardz',
+                'type': 'item',
+                'reverse': False,
+            },
+        ],
+    },
+    'card_b': {
+        'name': 'Card B',
+        'fields': [],
+        'relations': [
+            {'obj': 'carddef:card_a', 'varname': 'cardb', 'type': 'item', 'reverse': True},
+            {'obj': 'carddef:card_a', 'varname': 'cardsb', 'type': 'items', 'reverse': True},
+            {'obj': 'carddef:card_a', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': True},
+            {'obj': 'carddef:card_c', 'varname': 'cardb', 'type': 'item', 'reverse': True},
+            {'obj': 'carddef:card_c', 'varname': 'cardsb', 'type': 'items', 'reverse': True},
+            {'obj': 'carddef:card_c', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': True},
+        ],
+    },
+    'card_c': {
+        'name': 'Card C',
+        'fields': [
+            {'label': 'Card B', 'varname': 'cardb', 'type': 'item'},
+            {'label': 'Cards B', 'varname': 'cardsb', 'type': 'items'},
+            {'label': 'Block B', 'varname': 'blockb', 'type': 'block:b'},
+        ],
+        'relations': [
+            {'obj': 'carddef:card_b', 'varname': 'cardb', 'type': 'item', 'reverse': False},
+            {'obj': 'carddef:card_b', 'varname': 'cardsb', 'type': 'items', 'reverse': False},
+            {'obj': 'carddef:card_b', 'varname': 'blockb_cardb', 'type': 'item', 'reverse': False},
+            {'obj': 'carddef:card_a', 'varname': 'cardc', 'type': 'item', 'reverse': True},
+        ],
+    },
+    'card_d': {
+        'name': 'Card D',
+        'fields': [
+            {'label': 'Card D', 'varname': 'cardd-foo', 'type': 'item'},
+            {'label': 'Card E', 'varname': 'carde-foo', 'type': 'item'},
+        ],
+        'relations': [
+            {'obj': 'carddef:card_d', 'varname': 'cardd-foo', 'type': 'item', 'reverse': False},
+            {'obj': 'carddef:card_d', 'varname': 'cardd-foo', 'type': 'item', 'reverse': True},
+            {'obj': 'carddef:card_e', 'varname': 'carde-foo', 'type': 'item', 'reverse': False},
+        ],
+    },
+    'card_e': {
+        'name': 'Card E',
+        'fields': [
+            {'label': 'Card D', 'varname': 'cardd-bar', 'type': 'item'},
+        ],
+        'relations': [
+            {'obj': 'carddef:card_d', 'varname': 'cardd-bar', 'type': 'item', 'reverse': False},
+            {'obj': 'carddef:card_d', 'varname': 'carde-foo', 'type': 'item', 'reverse': True},
+        ],
+    },
+    'card_f': {
+        'name': 'Card F',
+        'fields': [
+            {'label': 'Card H', 'varname': 'cardh', 'type': 'item'},
+        ],
+        'relations': [
+            {'obj': 'carddef:card_h', 'varname': 'cardh', 'type': 'item', 'reverse': False},
+        ],
+    },
+    'card_g': {
+        'name': 'Card G',
+        'fields': [
+            {'label': 'Card H', 'varname': 'cardh', 'type': 'item'},
+        ],
+        'relations': [
+            {'obj': 'carddef:card_h', 'varname': 'cardh', 'type': 'item', 'reverse': False},
+        ],
+    },
+    'card_h': {
+        'name': 'Card H',
+        'fields': [],
+        'relations': [
+            {'obj': 'carddef:card_f', 'varname': 'cardh', 'type': 'item', 'reverse': True},
+            {'obj': 'carddef:card_g', 'varname': 'cardh', 'type': 'item', 'reverse': True},
+        ],
+    },
+}
+
+
+class MockUser:
+    email = 'foo@example.net'
+    is_authenticated = True
+    is_anonymous = False
+
+    def get_name_id(self):
+        return None
+
+
+class MockUserWithNameId:
+    email = 'foo@example.net'
+    is_authenticated = True
+    is_anonymous = False
+
+    def get_name_id(self):
+        return 'xyz'
+
+
+class MockedRequestResponse(mock.Mock):
+    status_code = 200
+
+    def json(self):
+        return json.loads(self.content)
+
+
+def get_data_from_url(url):
+    if '/api/formdefs/' in url:
+        return WCS_FORMDEFS_DATA
+    if '/api/categories/' in url:
+        if '/formdefs/' in url:
+            return WCS_CATEGORIES_FORMDEFS_DATA
+        return WCS_CATEGORIES_DATA
+    if '/api/user/forms' in url:
+        return WCS_USER_FORMS_DATA
+    if '/api/forms' in url:
+        return WCS_FORMS_DATA
+    if '/api/user/drafts' in url:
+        return WCS_USER_DRAFTS_DATA
+    if '/api/cards/@list' in url:
+        return WCS_CARDDEFS_DATA
+    m_schema = re.match(r'/api/cards/([a-z0-9_]+)/@schema', url)
+    if m_schema:
+        return WCS_CARDDEF_SCHEMAS.get(m_schema.group(1)) or {}
+    m_card = re.match(r'/api/cards/([a-z0-9_]+)/(\d+)/', url)
+    if m_card:
+        try:
+            return [d for d in WCS_CARDS_DATA[m_card.group(1)] if d['id'] == int(m_card.group(2))][0]
+        except IndexError:
+            return {}
+    m_card = re.match(r'/api/cards/([a-z0-9_]+)/([a-z0-9_]+)/(\d+)/', url)  # model/custom-view/id
+    if m_card:
+        try:
+            return [d for d in WCS_CARDS_DATA[m_card.group(1)] if d['id'] == int(m_card.group(3))][0]
+        except IndexError:
+            return {}
+    if 'api/cards/card_model_1/list/foo' in url:
+        return WCS_CARDS_CUSTOM_VIEW_DATA
+    m_list = re.match(r'/api/cards/([a-z0-9_]+)/list', url)
+    if m_list:
+        return WCS_CARDS_DATA.get(m_list.group(1)) or []
+    return []
+
+
+def mocked_requests_send(request, **kwargs):
+    request_url = urllib.parse.urlparse(request.url)
+    data = copy.deepcopy(get_data_from_url(request_url.path))
+
+    if not isinstance(data, list):
+        return MockedRequestResponse(content=json.dumps(data))
+
+    for elem in data:
+        for key in ['url', 'form_url', 'form_url_backoffice']:
+            if key not in elem:
+                continue
+            elem_url = elem[key]
+            elem[key] = '{scheme}://{netloc}{url}'.format(
+                scheme=request_url.scheme, netloc=request_url.netloc, url=elem_url
+            )
+
+    return MockedRequestResponse(content=json.dumps({'data': data}))
-- 
2.35.1

