From 402ef05fea72bc03b3c836cd22f0a0c25bf1e852 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 21 Sep 2020 13:53:39 +0200
Subject: [PATCH 2/2] wcs: add cell to list cards of a card model (#46768)

---
 combo/apps/wcs/__init__.py                    |   6 +-
 combo/apps/wcs/forms.py                       |  13 +-
 combo/apps/wcs/migrations/0020_cards.py       |  39 +++++
 combo/apps/wcs/models.py                      |  93 ++++++++++-
 combo/apps/wcs/templates/combo/wcs/cards.html |  23 +++
 tests/test_search.py                          |   6 +-
 tests/test_wcs.py                             | 155 +++++++++++++++++-
 7 files changed, 325 insertions(+), 10 deletions(-)
 create mode 100644 combo/apps/wcs/migrations/0020_cards.py
 create mode 100644 combo/apps/wcs/templates/combo/wcs/cards.html

diff --git a/combo/apps/wcs/__init__.py b/combo/apps/wcs/__init__.py
index 83e0542e..cdd9b259 100644
--- a/combo/apps/wcs/__init__.py
+++ b/combo/apps/wcs/__init__.py
@@ -111,9 +111,9 @@ class AppConfig(django.apps.AppConfig):
 
     def update_db_cache(self):
         from combo.data.models import CellBase
-        from .models import WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell
-        for cell in CellBase.get_cells(
-                cell_filter=lambda x: x in (WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell)):
+        from .models import WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell, WcsCardsCell
+        models_to_update = [WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell, WcsCardsCell]
+        for cell in CellBase.get_cells(cell_filter=lambda x: x in models_to_update):
             cell.save()
 
 
diff --git a/combo/apps/wcs/forms.py b/combo/apps/wcs/forms.py
index 09ebb753..6e10c8fb 100644
--- a/combo/apps/wcs/forms.py
+++ b/combo/apps/wcs/forms.py
@@ -23,7 +23,7 @@ from django.utils.translation import ugettext_lazy as _
 from combo.utils.forms import MultiSortWidget
 
 from .models import (WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell,
-                     WcsCurrentFormsCell, WcsCurrentDraftsCell)
+                     WcsCurrentFormsCell, WcsCurrentDraftsCell, WcsCardsCell)
 from .utils import get_wcs_options, get_wcs_services
 
 
@@ -38,6 +38,17 @@ class WcsFormCellForm(forms.ModelForm):
         self.fields['formdef_reference'].widget = forms.Select(choices=formdef_references)
 
 
+class WcsCardsCellForm(forms.ModelForm):
+    class Meta:
+        model = WcsCardsCell
+        fields = ('carddef_reference',)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        card_models = get_wcs_options('/api/cards/@list')
+        self.fields['carddef_reference'].widget = forms.Select(choices=card_models)
+
+
 class WcsCategoryCellForm(forms.ModelForm):
     class Meta:
         model = WcsCategoryCell
diff --git a/combo/apps/wcs/migrations/0020_cards.py b/combo/apps/wcs/migrations/0020_cards.py
new file mode 100644
index 00000000..e6fa6799
--- /dev/null
+++ b/combo/apps/wcs/migrations/0020_cards.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import combo.apps.wcs.models
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('data', '0045_link_list_limit'),
+        ('auth', '0008_alter_user_username_max_length'),
+        ('wcs', '0019_wcscurrentformscell_include_drafts'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='WcsCardsCell',
+            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')),
+                ('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': 'Cards',
+            },
+            bases=(models.Model, combo.apps.wcs.models.WcsBlurpMixin),
+        ),
+    ]
diff --git a/combo/apps/wcs/models.py b/combo/apps/wcs/models.py
index 61f25a03..69016c53 100644
--- a/combo/apps/wcs/models.py
+++ b/combo/apps/wcs/models.py
@@ -28,7 +28,7 @@ from django.utils.translation import ugettext_lazy as _
 
 from jsonfield import JSONField
 
-from combo.data.models import CellBase
+from combo.data.models import CellBase, Page
 from combo.data.library import register_cell_class
 from combo.utils import requests
 
@@ -38,6 +38,8 @@ from .utils import get_wcs_json, is_wcs_enabled, get_wcs_services
 invalid_reason_codes = {
     'wcs_form_not_defined': _('No form set'),
     'wcs_form_not_found': _('Invalid form'),
+    'wcs_card_not_defined': _('No card model set'),
+    'wcs_card_not_found': _('Invalid card model'),
     'wcs_category_not_defined': _('No category set'),
     'wcs_category_not_found': _('Invalid category'),
     'wcs_data_failure': _('Failed to get data'),
@@ -708,6 +710,95 @@ class CategoriesCell(WcsDataBaseCell):
         verbose_name = _('Form Categories')
 
 
+@register_cell_class
+class WcsCardsCell(CellBase, WcsBlurpMixin):
+    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):
+        if 'update_fields' in kwargs:
+            # don't populate the cache
+            return super().save(*args, **kwargs)
+
+        def populate_cache():
+            if self.carddef_reference:
+                wcs_key, card_slug = self.carddef_reference.split(':')
+                wcs_site = get_wcs_services().get(wcs_key)
+                card_models = get_wcs_json(wcs_site, 'api/cards/@list')
+
+                if not card_models:
+                    # can not retrieve data, don't report cell as invalid
+                    self.mark_as_valid()
+                    return
+
+                card_found = False
+                for card in card_models.get('data', []):
+                    slug = card['slug']
+                    if slug == card_slug:
+                        self.cached_title = card['title']
+                        self.save(update_fields=['cached_title'])
+                        card_found = True
+                        break
+                if card_found:
+                    self.mark_as_valid()
+                    return
+                else:
+                    return self.mark_as_invalid('wcs_card_not_found')
+            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))
+        extra_context['title'] = self.cached_title
+
+        pages_with_sub_slug = Page.objects.exclude(sub_slug='')
+        matching_pages = ([p for p in pages_with_sub_slug if '<%s_id>' % self.carddef_reference.split(':')[1] in p.sub_slug])
+        if matching_pages:
+            card_page = matching_pages[0]
+            extra_context['card_page_base_url'] = card_page.get_online_url()
+
+        try:
+            extra_context['cards'] = list(extra_context['cards'][self.wcs_site]['data'])
+        except (KeyError, TypeError):
+            # an error occured when getting the data
+            extra_context['cards'] = []
+
+        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 TrackingCodeInputCell(CellBase):
     is_enabled = classmethod(is_wcs_enabled)
diff --git a/combo/apps/wcs/templates/combo/wcs/cards.html b/combo/apps/wcs/templates/combo/wcs/cards.html
new file mode 100644
index 00000000..f0944ea8
--- /dev/null
+++ b/combo/apps/wcs/templates/combo/wcs/cards.html
@@ -0,0 +1,23 @@
+{% load assets i18n %}
+
+{% block cell-content %}
+
+{% block cell-header %}
+<h2>{{ title }}</h2>
+{% include "combo/asset_picture_fragment.html" %}
+{% endblock %}
+
+<div class="links-list cards-{{ slug }} list-of-cards">
+  <ul>
+    {% for data in cards %}
+    <li>
+      <a href="{% if card_page_base_url %}{{ card_page_base_url }}{{ data.id }}{% else %}{{ data.url }}{% endif %}"><span class="card-title">{{ data.text }}</span></a>
+    </li>
+    {% endfor %}
+  </ul>
+</div>
+
+{% if not cards %}<div class="empty-message"><p>{% trans "There are no cards." %}</p></div>{% endif %}
+{% include "combo/pagination.html" %}
+
+{% endblock %}
diff --git a/tests/test_search.py b/tests/test_search.py
index 7a9f80fd..c6113890 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -658,7 +658,7 @@ def test_wcs_search_engines(settings, app):
             assert '&include-anonymised=off' in engine['url']
 
     # create a page with sub_slug to enable card engines
-    Page.objects.create(slug='foo', title='Foo', sub_slug='(?P<foo_id>[a-z0-9]+')
+    Page.objects.create(slug='foo', title='Foo', sub_slug='(?P<foo_id>[a-z0-9]+)')
     with mock.patch('combo.apps.wcs.utils.get_wcs_json') as mock_wcs:
         # no card model found
         mock_wcs.return_value = {}
@@ -675,7 +675,7 @@ def test_wcs_search_engines(settings, app):
         assert len([x for x in search_engines.keys() if x.startswith('cards:')]) == 0
 
         # related page exists
-        Page.objects.create(slug='bar', title='Bar', sub_slug='(?P<bar_id>[a-z0-9]+')
+        Page.objects.create(slug='bar', title='Bar', sub_slug='(?P<bar_id>[a-z0-9]+)')
         search_engines = engines.get_engines()
         assert len([x for x in search_engines.keys() if x.startswith('cards:')]) == 1
         assert 'cards:c21f969b:bar' in search_engines.keys()
@@ -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) == 221
+        assert len(ctx.captured_queries) == 222
 
     SearchCell.objects.create(
         page=page, placeholder='content', order=0,
diff --git a/tests/test_wcs.py b/tests/test_wcs.py
index f333ca6f..c181ea30 100644
--- a/tests/test_wcs.py
+++ b/tests/test_wcs.py
@@ -23,7 +23,8 @@ from combo.apps.search.engines import engines
 from combo.apps.wcs.models import (
     WcsFormCell, WcsCurrentFormsCell,
     WcsFormsOfCategoryCell, WcsCurrentDraftsCell, WcsCategoryCell,
-    TrackingCodeInputCell, BackofficeSubmissionCell, WcsCareFormsCell)
+    TrackingCodeInputCell, BackofficeSubmissionCell, WcsCareFormsCell,
+    WcsCardsCell)
 
 from combo.apps.search.models import SearchCell
 from combo.apps.search.utils import index_site, search_site
@@ -81,6 +82,39 @@ WCS_USER_DRAFTS_DATA = [
      'form_status_is_endpoint': True, 'category_slug': 'test-9'},
 ]
 
+WCS_CARDDEFS_DATA = [
+    {'title': 'Card Model 1', 'slug': 'card_model_1'},
+    {'title': 'Card Model 2', 'slug': 'card_model_2'},
+    {'title': 'Card Model 3', 'slug': 'card_model_3'},
+]
+
+WCS_CARDS_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/'
+    },
+    {
+        '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/'
+    },
+]
+
 
 class MockUser(object):
     email = 'foo@example.net'
@@ -118,6 +152,10 @@ def get_data_from_url(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
+    if 'api/cards/' in url:
+        return WCS_CARDS_DATA
     return []
 
 
@@ -213,7 +251,8 @@ def test_form_cell_validity(mock_send):
     assert validity_info.invalid_since is not None
 
 
-def test_form_cell_load():
+@mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_form_cell_load(mock_send):
     page = Page(title='xxx', slug='test_form_cell_save_cache', template_name='standard')
     page.save()
     cell = WcsFormCell(page=page, placeholder='content', order=0)
@@ -858,6 +897,118 @@ def test_manager_current_forms(app, admin_user):
         settings.KNOWN_SERVICES = temp_settings
 
 
+@mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_cards_cell_setup(mock_send):
+    cell = WcsCardsCell()
+    form_class = cell.get_default_form_class()
+    form = form_class()
+    assert form.fields['carddef_reference'].widget.choices == [
+        ('default:card_model_1', 'test : Card Model 1'),
+        ('default:card_model_2', 'test : Card Model 2'),
+        ('default:card_model_3', 'test : Card Model 3'),
+        ('other:card_model_1', 'test2 : Card Model 1'),
+        ('other:card_model_2', 'test2 : Card Model 2'),
+        ('other:card_model_3', 'test2 : Card Model 3'),
+    ]
+
+
+@mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_cards_cell_save_cache(mock_send):
+    page = Page.objects.create(title='xxx', slug='test_cards_cell_save_cache', template_name='standard')
+    cell = WcsCardsCell(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.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())
+
+    # check content provided to search engine
+    assert cell.render_for_search() == ''
+
+    # artificially change title
+    WcsCardsCell.objects.filter(pk=cell.pk).update(cached_title='XXX')
+    assert WcsCardsCell.objects.get(pk=cell.pk).cached_title == 'XXX'
+    # run update db cache
+    appconfig = apps.get_app_config('wcs')
+    appconfig.update_db_cache()
+    assert WcsCardsCell.objects.get(pk=cell.pk).cached_title == 'Card Model 1'
+
+
+@mock.patch('combo.apps.wcs.utils.requests.send', side_effect=mocked_requests_send)
+def test_cards_cell_validity(mock_send):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardsCell.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
+
+    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_cards_cell_load(mock_send):
+    page = Page.objects.create(title='xxx', slug='test_cards', template_name='standard')
+    cell = WcsCardsCell(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_cards_cell_render(mock_send, context):
+    page = Page.objects.create(title='xxx', template_name='standard')
+    cell = WcsCardsCell(page=page, placeholder='content', order=0)
+    cell.carddef_reference = u'default:card_model_1'
+    cell.save()
+
+    # 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</h2>' in result
+    assert '<a href="http://127.0.0.1:8999/backoffice/data/card_model_1/11/"><span class="card-title">aa</span></a>' in result
+    assert '<a href="http://127.0.0.1:8999/backoffice/data/card_model_1/12/"><span class="card-title">bb</span></a>' in result
+    assert '<a href="http://127.0.0.1:8999/backoffice/data/card_model_1/13/"><span class="card-title">cc</span></a>' in result
+
+    # create a page with the correct subslug
+    Page.objects.create(slug='foo', title='Foo', sub_slug='(?P<card_model_1_id>[a-z0-9]+)')
+
+    result = cell.render(context)
+    assert '<h2>Card Model 1</h2>' in result
+    assert '<a href="/foo/11"><span class="card-title">aa</span></a>' in result
+    assert '<a href="/foo/12"><span class="card-title">bb</span></a>' in result
+    assert '<a href="/foo/13"><span class="card-title">cc</span></a>' in result
+
+
 def test_tracking_code_cell(app, nocache):
     page = Page(title='One', slug='index', template_name='standard')
     page.save()
-- 
2.28.0

