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')