diff --git a/.gitignore b/.gitignore
index ce6e7b60..46fa29de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ combo/apps/maps/static/css/combo.map.css
combo/apps/maps/static/css/combo.map.css.map
combo/apps/pwa/static/css/combo.manager.pwa.css
combo/apps/pwa/static/css/combo.manager.pwa.css.map
+combo/apps/family/static/css/combo.weekly_agenda.css
data/themes/gadjo/static/css/agent-portal.css
data/themes/gadjo/static/css/agent-portal.css.map
.cache
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..61263263 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 .
+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,47 @@ 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'
+ force_async = True
+
+ class Meta:
+ verbose_name = _('Weekly agenda cell')
+
+ class Media:
+ js = ('js/combo.weekly_agenda.js',)
+ css = {'all': ('css/combo.weekly_agenda.css',)}
+
+ @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/static/css/combo.weekly_agenda.scss b/combo/apps/family/static/css/combo.weekly_agenda.scss
new file mode 100644
index 00000000..7a08223b
--- /dev/null
+++ b/combo/apps/family/static/css/combo.weekly_agenda.scss
@@ -0,0 +1,95 @@
+.weeklyagenda-cell {
+ margin-top: 1em;
+ display: flex;
+ ul {
+ padding: 0;
+ margin: 0;
+ ul {
+ list-style: none;
+ }
+ }
+ & > ul {
+ width: 100%;
+ & > li {
+ display: none;
+ &.shown {
+ display: block;
+ }
+ }
+ }
+ button.previous-week,
+ button.next-week {
+ height: 3em;
+ z-index: 10;
+ }
+ button.next-week {
+ margin-left: 1em;
+ margin-right: 0;
+ }
+ .week-title {
+ display: block;
+ height: 3em;
+ line-height: 3em;
+ }
+ .no-activity {
+ color: #888;
+ }
+ li {
+ &.day-title {
+ margin: 1em 0 0.5em 0;
+ }
+ &.disabled {
+ color: #888;
+ }
+ & > span {
+ position: relative;
+ }
+ }
+ span > span {
+ padding-left: 1.8em;
+ &::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;
+ }
+ &::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;
+ }
+ }
+ & [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;
+ & .week-title {
+ margin: 0 4em;
+ line-height: 150%;
+ text-align: center;
+ }
+ }
+}
diff --git a/combo/apps/family/static/js/combo.weekly_agenda.js b/combo/apps/family/static/js/combo.weekly_agenda.js
new file mode 100644
index 00000000..7c605473
--- /dev/null
+++ b/combo/apps/family/static/js/combo.weekly_agenda.js
@@ -0,0 +1,46 @@
+$(function () {
+ init_agenda = function(cell_id) {
+ $('.weeklyagenda-cell-' + cell_id + ' .previous-week').on('click', function() {
+ var $cell = $(this).parents('.weeklyagenda-cell-' + cell_id);
+ var $prev = $('li.shown', $cell).prev();
+ if ($prev.length) { $('li.shown', $cell).removeClass('shown'); $prev.addClass('shown'); }
+ return false;
+ });
+ $('.weeklyagenda-cell-' + cell_id + ' .next-week').on('click', function() {
+ var $cell = $(this).parents('.weeklyagenda-cell-' + cell_id);
+ var $next = $('li.shown', $cell).next();
+ if ($next.length) { $('li.shown', $cell).removeClass('shown'); $next.addClass('shown'); }
+ return false;
+ });
+ $('.weeklyagenda-cell-' + cell_id + ' 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_id + ' 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_id).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');
+ }
+ });
+ }
+ $(document).on('combo:cell-loaded', function(ev, cell) {
+ init_agenda($('.weeklyagenda-cell', $(cell)).data('cell-id'));
+ });
+});
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..5c2063f4
--- /dev/null
+++ b/combo/apps/family/templates/family/weekly_agenda.html
@@ -0,0 +1,51 @@
+{% load i18n %}
+{% block cell-content %}
+{% if json.data %}
+
+{% if cell.title %}
+
+ {{ cell.title }}
+
+{% endif %}
+
+
+{% 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 %}
+
←
+
+
→
+{% endspaceless %}
+{% endwith %}
+{% endwith %}
+
+{% 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')