From f9881c7a5b62a4236cf93256ed647d6f10acb100 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 22 Sep 2020 15:23:42 +0200
Subject: [PATCH] wcs: add cell to display card details (#46767)

---
 combo/apps/wcs/__init__.py                   |   4 +-
 combo/apps/wcs/migrations/0021_card.py       |  41 ++++++
 combo/apps/wcs/models.py                     | 116 ++++++++++++---
 combo/apps/wcs/templates/combo/wcs/card.html |  31 ++++
 tests/test_search.py                         |   2 +-
 tests/test_wcs.py                            | 144 ++++++++++++++++++-
 6 files changed, 311 insertions(+), 27 deletions(-)
 create mode 100644 combo/apps/wcs/migrations/0021_card.py
 create mode 100644 combo/apps/wcs/templates/combo/wcs/card.html

diff --git a/combo/apps/wcs/__init__.py b/combo/apps/wcs/__init__.py
index cdd9b259..a82fa69e 100644
--- a/combo/apps/wcs/__init__.py
+++ b/combo/apps/wcs/__init__.py
@@ -111,8 +111,8 @@ class AppConfig(django.apps.AppConfig):
 
     def update_db_cache(self):
         from combo.data.models import CellBase
-        from .models import WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell, WcsCardsCell
-        models_to_update = [WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell, WcsCardsCell]
+        from .models import WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell, WcsCardsCell, WcsCardInfosCell
+        models_to_update = [WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell, WcsCardsCell, WcsCardInfosCell]
         for cell in CellBase.get_cells(cell_filter=lambda x: x in models_to_update):
             cell.save()
 
diff --git a/combo/apps/wcs/migrations/0021_card.py b/combo/apps/wcs/migrations/0021_card.py
new file mode 100644
index 00000000..c23c2bec
--- /dev/null
+++ b/combo/apps/wcs/migrations/0021_card.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import combo.apps.wcs.models
+from django.db import migrations, models
+import django.db.models.deletion
+import jsonfield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0008_alter_user_username_max_length'),
+        ('data', '0045_link_list_limit'),
+        ('wcs', '0020_cards'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='WcsCardInfosCell',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('placeholder', models.CharField(max_length=20)),
+                ('order', models.PositiveIntegerField()),
+                ('slug', models.SlugField(blank=True, verbose_name='Slug')),
+                ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
+                ('public', models.BooleanField(default=True, verbose_name='Public')),
+                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
+                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
+                ('carddef_reference', models.CharField(max_length=150, verbose_name='Card Model')),
+                ('cached_title', models.CharField(max_length=150, verbose_name='Title')),
+                ('cached_json', jsonfield.fields.JSONField(blank=True, default=dict)),
+                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
+                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
+            ],
+            options={
+                'verbose_name': 'Card Information Cell',
+            },
+            bases=(combo.apps.wcs.models.CardMixin, models.Model),
+        ),
+    ]
diff --git a/combo/apps/wcs/models.py b/combo/apps/wcs/models.py
index 69016c53..c8475e56 100644
--- a/combo/apps/wcs/models.py
+++ b/combo/apps/wcs/models.py
@@ -710,22 +710,43 @@ class CategoriesCell(WcsDataBaseCell):
         verbose_name = _('Form Categories')
 
 
+class CardMixin(object):
+    invalid_reason_codes = invalid_reason_codes
+
+    def is_relevant(self, context):
+        return bool(self.carddef_reference)
+
+    def get_default_form_class(self):
+        from .forms import WcsCardsCellForm
+        return WcsCardsCellForm
+
+    @property
+    def wcs_site(self):
+        return self.carddef_reference.split(':')[0]
+
+    def get_additional_label(self):
+        if not self.cached_title:
+            return
+        return self.cached_title
+
+
 @register_cell_class
-class WcsCardsCell(CellBase, WcsBlurpMixin):
+class WcsCardsCell(CardMixin, WcsBlurpMixin, CellBase):
     carddef_reference = models.CharField(_('Card Model'), max_length=150)
     cached_title = models.CharField(_('Title'), max_length=150)
 
     template_name = 'combo/wcs/cards.html'
     variable_name = 'cards'
-    invalid_reason_codes = invalid_reason_codes
 
     class Meta:
         verbose_name = _('Cards')
 
     def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+
         if 'update_fields' in kwargs:
             # don't populate the cache
-            return super().save(*args, **kwargs)
+            return
 
         def populate_cache():
             if self.carddef_reference:
@@ -754,23 +775,11 @@ class WcsCardsCell(CellBase, WcsBlurpMixin):
             else:
                 return self.mark_as_invalid('wcs_card_not_defined')
 
-        super().save(*args, **kwargs)
         populate_cache()
 
-    def is_relevant(self, context):
-        return bool(self.carddef_reference)
-
-    @property
-    def wcs_site(self):
-        return self.carddef_reference.split(':')[0]
-
     def get_api_url(self, context):
         return 'api/cards/%s/list' % self.carddef_reference.split(':')[1]
 
-    def get_default_form_class(self):
-        from .forms import WcsCardsCellForm
-        return WcsCardsCellForm
-
     def get_cell_extra_context(self, context):
         extra_context = super().get_cell_extra_context(context)
         extra_context.update(WcsBlurpMixin.get_cell_extra_context(self, context))
@@ -790,15 +799,82 @@ class WcsCardsCell(CellBase, WcsBlurpMixin):
 
         return extra_context
 
-    def get_additional_label(self):
-        if not self.cached_title:
-            return
-        return self.cached_title
-
     def render_for_search(self):
         return ''
 
 
+@register_cell_class
+class WcsCardInfosCell(CardMixin, CellBase):
+    carddef_reference = models.CharField(_('Card Model'), max_length=150)
+
+    cached_title = models.CharField(_('Title'), max_length=150)
+    cached_json = JSONField(blank=True)
+
+    is_enabled = classmethod(is_wcs_enabled)
+
+    template_name = 'combo/wcs/card.html'
+
+    class Meta:
+        verbose_name = _('Card Information Cell')
+
+    def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+
+        if 'update_fields' in kwargs:
+            # don't populate the cache
+            return
+
+        def populate_cache():
+            if self.carddef_reference:
+                wcs_key, card_slug = self.carddef_reference.split(':')
+                wcs_site = get_wcs_services().get(wcs_key)
+                card_schema = get_wcs_json(wcs_site, 'api/cards/%s/@schema' % card_slug)
+
+                if not card_schema:
+                    # can not retrieve data, don't report cell as invalid
+                    self.mark_as_valid()
+                    return
+
+                if card_schema.get('err') == 1:
+                    if card_schema.get('err_class') == 'Page not found':
+                        self.mark_as_invalid('wcs_card_not_found')
+                    return
+
+                self.cached_title = card_schema['name']
+                self.cached_json = card_schema
+                self.save(update_fields=['cached_title', 'cached_json'])
+                self.mark_as_valid()
+            else:
+                self.mark_as_invalid('wcs_card_not_defined')
+
+        populate_cache()
+
+    def get_cell_extra_context(self, context):
+        extra_context = super().get_cell_extra_context(context)
+        extra_context['title'] = self.cached_title
+        extra_context['schema'] = self.cached_json
+
+        card_slug = self.carddef_reference.split(':')[1]
+        card_id = '%s_id' % card_slug
+        if not context.get(card_id):
+            return extra_context
+        api_url = 'api/cards/%s/%s/' % (card_slug, context.get(card_id))
+
+        wcs_site = get_wcs_services().get(self.wcs_site)
+
+        response = requests.get(
+                api_url,
+                remote_service=wcs_site,
+                user=getattr(context.get('request'), 'user', None),
+                cache_duration=5,
+                raise_if_not_cached=not(context.get('synchronous')),
+                log_errors=False)
+        if response.status_code == 200:
+            extra_context['card'] = response.json()
+
+        return extra_context
+
+
 @register_cell_class
 class TrackingCodeInputCell(CellBase):
     is_enabled = classmethod(is_wcs_enabled)
diff --git a/combo/apps/wcs/templates/combo/wcs/card.html b/combo/apps/wcs/templates/combo/wcs/card.html
new file mode 100644
index 00000000..355362cc
--- /dev/null
+++ b/combo/apps/wcs/templates/combo/wcs/card.html
@@ -0,0 +1,31 @@
+{% load assets i18n %}
+
+{% block cell-content %}
+
+<div class="card">
+{% block cell-header %}
+<h2>{{ title }}{% if card %} - {{ card.text }}{% endif %}</h2>
+{% include "combo/asset_picture_fragment.html" %}
+{% endblock %}
+
+{% if card %}
+{% for field in schema.fields %}
+  {% with card.fields|get:field.varname as value %}
+  <p>
+    <span class="label">{{ field.label }}</span>
+    {% if field.type == "date" %}
+    <span class="value">{{ value|date }}</span>
+    {% elif field.type == "bool" and value is not None %}
+    <span class="value">{{ value|yesno }}</span>
+    {% else %}
+    <span class="value">{{ value|default:"" }}</span>
+    {% endif %}
+  </p>
+  {% endwith %}
+{% endfor %}
+{% else %}
+<p>{% trans 'Unknown Card' %}</p>
+{% endif %}
+</div>
+
+{% endblock %}
diff --git a/tests/test_search.py b/tests/test_search.py
index c6113890..ea385d7c 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -815,7 +815,7 @@ def test_index_site_num_queries(settings, app):
     assert IndexedCell.objects.count() == 50
     with CaptureQueriesContext(connection) as ctx:
         index_site()
-        assert len(ctx.captured_queries) == 222
+        assert len(ctx.captured_queries) == 223
 
     SearchCell.objects.create(
         page=page, placeholder='content', order=0,
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index c181ea30..28ab0fb2 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -24,7 +24,7 @@ from combo.apps.wcs.models import (
     WcsFormCell, WcsCurrentFormsCell,
     WcsFormsOfCategoryCell, WcsCurrentDraftsCell, WcsCategoryCell,
     TrackingCodeInputCell, BackofficeSubmissionCell, WcsCareFormsCell,
-    WcsCardsCell)
+    WcsCardsCell, WcsCardInfosCell)
 
 from combo.apps.search.models import SearchCell
 from combo.apps.search.utils import index_site, search_site
@@ -115,6 +115,33 @@ WCS_CARDS_DATA = [
     },
 ]
 
+WCS_CARDDEF_SCHEMA = {
+    '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': 'Related', 'varname': 'related', 'type': 'item'},
+    ]
+}
+
+WCS_CARD_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/',
+    'fields': {
+        'fielda': 'a',
+        'fieldb': True,
+        'fieldc': '2020-09-28',
+        'related': 'Foo Bar',
+        'related_raw': 42,
+        'related_structured': {'id': 42, 'text': 'blah'},
+    }
+}
+
 
 class MockUser(object):
     email = 'foo@example.net'
@@ -154,7 +181,11 @@ def get_data_from_url(url):
         return WCS_USER_DRAFTS_DATA
     if '/api/cards/@list' in url:
         return WCS_CARDDEFS_DATA
-    if 'api/cards/' in url:
+    if '/api/cards/card_model_1/@schema' in url:
+        return WCS_CARDDEF_SCHEMA
+    if 'api/cards/card_model_1/11/' in url:
+        return WCS_CARD_DATA
+    if 'api/cards/card_model_1/list' in url:
         return WCS_CARDS_DATA
     return []
 
@@ -162,6 +193,10 @@ def get_data_from_url(url):
 def mocked_requests_send(request, **kwargs):
     request_url = urlparse.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_backoffice']:
             if key not in elem:
@@ -897,9 +932,10 @@ def test_manager_current_forms(app, admin_user):
         settings.KNOWN_SERVICES = temp_settings
 
 
+@pytest.mark.parametrize('cell_class', [WcsCardsCell, WcsCardInfosCell])
 @mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
-def test_cards_cell_setup(mock_send):
-    cell = WcsCardsCell()
+def test_card_cells_setup(mock_send, cell_class):
+    cell = cell_class()
     form_class = cell.get_default_form_class()
     form = form_class()
     assert form.fields['carddef_reference'].widget.choices == [
@@ -1009,6 +1045,106 @@ def test_cards_cell_render(mock_send, context):
     assert '<a href="/foo/13"><span class="card-title">cc</span></a>' in result
 
 
+@mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_card_cell_save_cache(mock_send):
+    page = Page.objects.create(title='xxx', slug='test_cards_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('combo.apps.wcs.utils.requests.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 carddefs, don't set cell as invalid
+    with mock.patch('combo.apps.wcs.models.requests.get') as requests_get:
+        mock_json = mock.Mock(status_code=404)
+        requests_get.return_value = mock_json
+        cell.save()
+    assert ValidityInfo.objects.exists() is False
+
+    mock_send.side_effect = lambda *a, **k: MockedRequestResponse(content=json.dumps({'err': 1, 'err_class': 'Page not found'}))
+    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('combo.apps.wcs.utils.requests.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('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_card_cell_render(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardInfosCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = u'default:card_model_1'
+    cell.save()
+
+    # card id not in context
+    assert 'card_model_1_id' not in context
+    result = cell.render(context)
+    assert '<h2>Card Model 1</h2>' in result
+    assert '<p>Unknown Card</p>' in result
+
+    context['card_model_1_id'] = 11
+
+    # query should fail as nothing is cached
+    cache.clear()
+    with pytest.raises(NothingInCacheException):
+        result = cell.render(context)
+
+    context['synchronous'] = True  # to get fresh content
+
+    result = cell.render(context)
+    assert '<h2>Card Model 1 - aa</h2>' in result
+    assert '<span class="label">Field A</span>\n    \n    <span class="value">a</span>' in result
+    assert '<span class="label">Field B</span>\n    \n    <span class="value">yes</span>' in result
+    assert '<span class="label">Field C</span>\n    \n    <span class="value">Sept. 28, 2020</span>' in result
+    assert '<span class="label">Related</span>\n    \n    <span class="value">Foo Bar</span>' in result
+    assert 'related_raw' not in result
+    assert 'related_structured' not in result
+
+
 def test_tracking_code_cell(app, nocache):
     page = Page(title='One', slug='index', template_name='standard')
     page.save()
-- 
2.28.0

