From 47b405644fadb68c76753161f9ce760494e73e59 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 9 Aug 2021 15:47:48 +0200
Subject: [PATCH] family: weekly agenda cell (#56027)

---
 .../migrations/0006_weekly_agenda_cell.py     |  50 +++++
 combo/apps/family/models.py                   |  43 +++-
 .../templates/family/weekly_agenda.html       | 190 ++++++++++++++++++
 combo/settings.py                             |   1 +
 tests/test_family.py                          |  92 +++++++++
 tests/test_manager.py                         |   8 +-
 6 files changed, 379 insertions(+), 5 deletions(-)
 create mode 100644 combo/apps/family/migrations/0006_weekly_agenda_cell.py
 create mode 100644 combo/apps/family/templates/family/weekly_agenda.html
 create mode 100644 tests/test_family.py

diff --git a/combo/apps/family/migrations/0006_weekly_agenda_cell.py b/combo/apps/family/migrations/0006_weekly_agenda_cell.py
new file mode 100644
index 00000000..336dad93
--- /dev/null
+++ b/combo/apps/family/migrations/0006_weekly_agenda_cell.py
@@ -0,0 +1,50 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0011_update_proxy_permissions'),
+        ('data', '0047_auto_20210723_1318'),
+        ('family', '0005_familyinfoscell_template_name'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='WeeklyAgendaCell',
+            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'
+                    ),
+                ),
+                (
+                    'template_name',
+                    models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
+                ),
+                ('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)),
+                ('title', models.CharField(blank=True, max_length=150, verbose_name='Title')),
+                ('agenda_reference', models.CharField(max_length=128, verbose_name='Agenda')),
+                ('user_external_id_key', models.CharField(max_length=50)),
+                ('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': 'Weekly agenda cell',
+            },
+        ),
+    ]
diff --git a/combo/apps/family/models.py b/combo/apps/family/models.py
index 88469257..d27e38eb 100644
--- a/combo/apps/family/models.py
+++ b/combo/apps/family/models.py
@@ -14,10 +14,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from django.conf import settings
+from django.db import models
 from django.utils.translation import ugettext_lazy as _
 
 from combo.data.library import register_cell_class
-from combo.data.models import CellBase
+from combo.data.models import CellBase, JsonCellBase
 
 from .utils import get_family, is_family_enabled
 
@@ -50,3 +52,42 @@ class FamilyInfosCell(CellBase):
         if response.status_code == 200:
             return {'family': response.json()}
         return {'error': _('An error occured while retrieving family details.')}
+
+
+@register_cell_class
+class WeeklyAgendaCell(JsonCellBase):
+    title = models.CharField(_('Title'), max_length=150, blank=True)
+    agenda_reference = models.CharField(_('Agenda'), max_length=128)
+    user_external_id_key = models.CharField(max_length=50)
+
+    default_template_name = 'family/weekly_agenda.html'
+
+    class Meta:
+        verbose_name = _('Weekly agenda cell')
+
+    @classmethod
+    def is_enabled(cls):
+        return settings.PUBLIK_FAMILY_CELL_ENABLED
+
+    @property
+    def url(self):
+        chrono = list(settings.KNOWN_SERVICES.get('chrono').values())[0]
+        chrono_url = chrono.get('url') or ''
+        if not chrono_url.endswith('/'):
+            chrono_url += '/'
+        # XXX events=all param is not supported for now
+        return (
+            '%sapi/agendas/datetimes/?agendas=%s&user_external_id=%s:{{ user_external_id|default:user_nameid }}'
+            % (chrono_url, self.agenda_reference, self.user_external_id_key)
+        )
+
+    def is_visible(self, **kwargs):
+        user = kwargs.get('user')
+        if not user or user.is_anonymous:
+            return False
+        return super().is_visible(**kwargs)
+
+    def get_cell_extra_context(self, context):
+        if context.get('placeholder_search_mode'):
+            return {}
+        return super().get_cell_extra_context(context)
diff --git a/combo/apps/family/templates/family/weekly_agenda.html b/combo/apps/family/templates/family/weekly_agenda.html
new file mode 100644
index 00000000..030e231f
--- /dev/null
+++ b/combo/apps/family/templates/family/weekly_agenda.html
@@ -0,0 +1,190 @@
+{% load i18n %}
+{% block cell-content %}
+{% if json.data %}
+
+{% if cell.title %}
+<h2>
+    {{ cell.title }}
+</h2>
+{% endif %}
+
+<div class="weeklyagenda-cell weeklyagenda-cell-{{ cell.pk }}">
+{% with first_monday=json.data.0.date|date|adjust_to_week_monday last_item=json.data|last %}
+{% with last_day=last_item.date|date|adjust_to_week_monday|add_days:6 %}
+{% now 'Y-m-d' as now %}
+
+{% spaceless %}
+<button class="previous-week">←</button>
+<ul>
+{% for day in first_monday|iterate_days_until:last_day %}
+{% if day.weekday == 0 %}
+{% with sunday=day|add_days:6 %}
+<li class="week"><span class="week-title">{% blocktrans with day_date=day|date:"d/m" sunday_date=sunday|date:"d/m" %}Semaine<br class="weekbreak"> du {{ day_date }} au {{ sunday_date }}{% endblocktrans %}</span><ul>
+{% endwith %}
+{% endif %}
+<li class="day-title {% if now == day|date:"Y-m-d" %}current{% endif %}" data-weekday="{{ day.weekday }}"><strong>{{ day|date:"l d/m" }}</strong></li>
+  {% with day_str=day|date:"Y-m-d" %}
+    {% for item in json.data %}
+      {% if item.date == day_str %}
+
+  <li class="activity {% if item.disabled %}disabled{% endif %}"
+      {% if item.booked_for_external_user %}data-status="green"{% endif %}
+   ><span><span>{{ item.label }}</span></span>
+   </li>
+
+      {% endif %}
+    {% endfor %}
+  {% endwith %}
+{% if day.weekday == 6 %}
+</ul>
+<p class="no-activity">{% trans "No activity this week" %}</p>
+</li>
+{% endif %}
+{% endfor %}
+</ul>
+<button class="next-week">→</button>
+{% endspaceless %}
+{% endwith %}
+{% endwith %}
+
+<style>
+.weeklyagenda-cell {
+        margin-top: 1em;
+        display: flex;
+}
+.weeklyagenda-cell ul {
+        padding: 0;
+        margin: 0;
+}
+.weeklyagenda-cell > ul {
+        width: 100%;
+}
+.weeklyagenda-cell > ul > li {
+        display: none;
+}
+.weeklyagenda-cell > ul > li.shown {
+        display: block;
+}
+.weeklyagenda-cell button.previous-week,
+.weeklyagenda-cell button.next-week {
+        height: 3em;
+        z-index: 10;
+}
+.weeklyagenda-cell button.next-week {
+        margin-left: 1em;
+        margin-right: 0;
+}
+.weeklyagenda-cell .week-title {
+        display: block;
+        height: 3em;
+        line-height: 3em;
+}
+.weeklyagenda-cell .no-activity {
+        color: #888;
+}
+.weeklyagenda-cell li.day-title {
+        margin: 1em 0 0.5em 0;
+}
+.weeklyagenda-cell ul ul {
+        list-style: none;
+}
+.weeklyagenda-cell li.disabled {
+        color: #888;
+}
+.weeklyagenda-cell li > span {
+        position: relative;
+}
+.weeklyagenda-cell span > span {
+        padding-left: 1.8em;
+}
+.weeklyagenda-cell span > span::before {
+        display: block;
+        content: '';
+        position: absolute;
+        margin: auto;
+        height: calc(0.66rem + 2px);
+        width: calc(0.66rem + 2px);
+        background: transparent;
+        top: 0.33rem;
+        left: 0;
+        border: 1px solid #aaa;
+        border-radius: 2px;
+}
+.weeklyagenda-cell span > span::after {
+        display: block;
+        content: '';
+        position: absolute;
+        margin: auto;
+        height: calc(0.66rem);
+        width: calc(0.66rem);
+        background: transparent;
+        transition: background 0.1s linear;
+        top: calc(0.33rem + 1px);
+        left: 1px;
+}
+.weeklyagenda-cell [data-status=green] span > span::after {
+        background: #3c3;
+}
+
+br.weekbreak { display: none; }
+
+@media screen and (max-width: 500px) {
+
+  br.weekbreak { display: block; }
+
+  div.weeklyagenda-cell > ul {
+    margin: 0 -4em;
+  }
+  div.weeklyagenda-cell > ul .week-title {
+    margin: 0 4em;
+    line-height: 150%;
+    text-align: center;
+  }
+
+}
+</style>
+<script>
+$('.weeklyagenda-cell-{{ cell.pk }} .previous-week').on('click', function() {
+  var $cell = $(this).parents('.weeklyagenda-cell-{{ cell.pk }}');
+  var $prev = $('li.shown', $cell).prev();
+  if ($prev.length) { $('li.shown', $cell).removeClass('shown'); $prev.addClass('shown'); }
+  return false;
+});
+$('.weeklyagenda-cell-{{ cell.pk }} .next-week').on('click', function() {
+  var $cell = $(this).parents('.weeklyagenda-cell-{{ cell.pk }}');
+  var $next = $('li.shown', $cell).next();
+  if ($next.length) { $('li.shown', $cell).removeClass('shown'); $next.addClass('shown'); }
+  return false;
+});
+$('.weeklyagenda-cell-{{ cell.pk }} li.day-title').each(function(idx, elem) {
+  /* hide empty days */
+  var $next = $(elem).next();
+  if ($next.length == 0 || $next.is('.day-title')) {
+    $(elem).hide();
+  }
+});
+$('.weeklyagenda-cell-{{ cell.pk }} li.week').each(function(idx, elem) {
+  /* hide no-activity message if not empty */
+  if ($('.activity', $(elem)).length > 0) {
+    $('.no-activity', $(elem)).hide();
+  }
+  /* hide booking button if all items are disabled */
+  if ($('.activity', $(elem)).not('.disabled').length == 0) {
+    $('.booking-btn', $(elem)).hide();
+  }
+});
+$('.weeklyagenda-cell-{{ cell.pk }}').each(function(idx, elem) {
+  /* init first week shown */
+  var $cell = $(this);
+  $('li', $cell).removeClass('shown');
+  if ($('li.day-title.current', $cell).length) {
+    $('li.day-title.current', $cell).parent().parent().addClass('shown');
+  } else {
+    $('li', $cell).first().addClass('shown');
+  }
+});
+
+</script>
+
+{% endif %}
+{% endblock %}
diff --git a/combo/settings.py b/combo/settings.py
index edd90e46..952850a0 100644
--- a/combo/settings.py
+++ b/combo/settings.py
@@ -366,6 +366,7 @@ PWA_NOTIFICATION_ICON_URL = None
 # hide work-in-progress/experimental/broken/legacy/whatever cells for now
 BOOKING_CALENDAR_CELL_ENABLED = False
 LEGACY_CHART_CELL_ENABLED = False
+PUBLIK_FAMILY_CELL_ENABLED = False
 
 
 def debug_show_toolbar(request):
diff --git a/tests/test_family.py b/tests/test_family.py
new file mode 100644
index 00000000..867d2435
--- /dev/null
+++ b/tests/test_family.py
@@ -0,0 +1,92 @@
+import json
+from unittest import mock
+
+import pytest
+from django.core.cache import cache
+from django.test.client import RequestFactory
+
+from combo.apps.family.models import WeeklyAgendaCell
+from combo.data.models import Page
+from combo.utils import NothingInCacheException
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def context():
+    ctx = {'request': RequestFactory().get('/')}
+    ctx['request'].user = None
+    ctx['request'].session = {}
+    return ctx
+
+
+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 test_weeklyagenda_cell(settings, context):
+    settings.PUBLIK_FAMILY_CELL_ENABLED = True
+
+    page = Page.objects.create(title='Family', slug='index', template_name='standard')
+    cell = WeeklyAgendaCell.objects.create(page=page, placeholder='content', order=0)
+
+    context['request'].user = MockUser()
+
+    # query should fail as nothing is cached
+    cache.clear()
+    with pytest.raises(NothingInCacheException):
+        cell.render(context)
+
+    context['synchronous'] = True  # to get fresh content
+
+    data = {'data': []}
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
+        cell.render(context)
+        # wrong url
+        assert (
+            requests_get.call_args_list[0][0][0]
+            == 'http://chrono.example.org/api/agendas/datetimes/?agendas=&user_external_id=:'
+        )
+
+    cell.agenda_reference = 'some-agenda'
+    cell.save()
+    context['request'].user = MockUserWithNameId()
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
+        cell.render(context)
+        assert (
+            requests_get.call_args_list[0][0][0]
+            == 'http://chrono.example.org/api/agendas/datetimes/?agendas=some-agenda&user_external_id=:xyz'
+        )
+
+    cell.user_external_id_key = 'some-key'
+    cell.save()
+    with mock.patch('combo.utils.requests.get') as requests_get:
+        requests_get.return_value = MockedRequestResponse(content=json.dumps(data))
+        cell.render(context)
+        assert (
+            requests_get.call_args_list[0][0][0]
+            == 'http://chrono.example.org/api/agendas/datetimes/?agendas=some-agenda&user_external_id=some-key:xyz'
+        )
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 264f20d9..188de2bb 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -880,7 +880,7 @@ def test_site_export_import_json(app, admin_user):
     resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json')
     with CaptureQueriesContext(connection) as ctx:
         resp = resp.form.submit()
-        assert len(ctx.captured_queries) in [298, 299]
+        assert len(ctx.captured_queries) in [303, 304]
     assert Page.objects.count() == 4
     assert PageSnapshot.objects.all().count() == 4
 
@@ -891,7 +891,7 @@ def test_site_export_import_json(app, admin_user):
     resp.form['site_file'] = Upload('site-export.json', site_export, 'application/json')
     with CaptureQueriesContext(connection) as ctx:
         resp = resp.form.submit()
-        assert len(ctx.captured_queries) == 268
+        assert len(ctx.captured_queries) == 272
     assert set(Page.objects.get(slug='one').related_cells['cell_types']) == {'data_textcell', 'data_linkcell'}
     assert Page.objects.count() == 4
     assert LinkCell.objects.count() == 2
@@ -2222,7 +2222,7 @@ def test_page_versionning(app, admin_user):
 
     with CaptureQueriesContext(connection) as ctx:
         resp2 = resp.click('view', index=1)
-        assert len(ctx.captured_queries) == 70
+        assert len(ctx.captured_queries) == 71
     assert Page.snapshots.latest('pk').related_cells == {'cell_types': ['data_textcell']}
     assert resp2.text.index('Hello world') < resp2.text.index('Foobar3')
 
@@ -2283,7 +2283,7 @@ def test_page_versionning(app, admin_user):
     resp = resp.click('restore', index=6)
     with CaptureQueriesContext(connection) as ctx:
         resp = resp.form.submit().follow()
-        assert len(ctx.captured_queries) == 142
+        assert len(ctx.captured_queries) == 144
 
     resp2 = resp.click('See online')
     assert resp2.text.index('Foobar1') < resp2.text.index('Foobar2') < resp2.text.index('Foobar3')
-- 
2.30.2

