From 1d2d3a8f16636da2f090f28d0500c8b39748205d Mon Sep 17 00:00:00 2001 From: Josue Kouka Date: Wed, 17 May 2017 18:19:07 +0200 Subject: [PATCH] add calendar cell model (#16393) --- combo/apps/chrono/README | 10 +++ combo/apps/chrono/__init__.py | 28 +++++++ combo/apps/chrono/models.py | 69 +++++++++++++++ combo/apps/chrono/static/chrono/chrono.css | 95 +++++++++++++++++++++ combo/apps/chrono/static/chrono/chrono.js | 76 +++++++++++++++++ combo/apps/chrono/templates/chrono/calendar.html | 10 +++ combo/apps/chrono/urls.py | 24 ++++++ combo/apps/chrono/utils.py | 61 ++++++++++++++ combo/apps/chrono/views.py | 54 ++++++++++++ combo/settings.py | 2 + tests/test_calendar.py | 102 +++++++++++++++++++++++ 11 files changed, 531 insertions(+) create mode 100644 combo/apps/chrono/README create mode 100644 combo/apps/chrono/__init__.py create mode 100644 combo/apps/chrono/models.py create mode 100644 combo/apps/chrono/static/chrono/chrono.css create mode 100644 combo/apps/chrono/static/chrono/chrono.js create mode 100644 combo/apps/chrono/templates/chrono/calendar.html create mode 100644 combo/apps/chrono/urls.py create mode 100644 combo/apps/chrono/utils.py create mode 100644 combo/apps/chrono/views.py create mode 100644 tests/test_calendar.py diff --git a/combo/apps/chrono/README b/combo/apps/chrono/README new file mode 100644 index 0000000..7049345 --- /dev/null +++ b/combo/apps/chrono/README @@ -0,0 +1,10 @@ +Combo calendar cell +================= + +To be visible, this cell needs a 'chrono' entry in settings.KNOWN_SERVICES + +session_vars are defineed such as: +{ + "my_session_var1_name": "value", + "my_session_var2_name": "ref_to_received_key", +} diff --git a/combo/apps/chrono/__init__.py b/combo/apps/chrono/__init__.py new file mode 100644 index 0000000..feef274 --- /dev/null +++ b/combo/apps/chrono/__init__.py @@ -0,0 +1,28 @@ +# combo - content management system +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import django.apps + + +class AppConfig(django.apps.AppConfig): + name = 'combo.apps.chrono' + + def get_before_urls(self): + from . import urls + return urls.urlpatterns + + +default_app_config = 'combo.apps.chrono.AppConfig' diff --git a/combo/apps/chrono/models.py b/combo/apps/chrono/models.py new file mode 100644 index 0000000..dbf656e --- /dev/null +++ b/combo/apps/chrono/models.py @@ -0,0 +1,69 @@ +# combo - content management system +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +from datetime import datetime + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils.dateparse import parse_time +from django.core.urlresolvers import reverse + +from combo.data.models import CellBase +from combo.data.library import register_cell_class +from .utils import is_chrono_enabled + +from jsonfield import JSONField + + +@register_cell_class +class CalendarCell(CellBase): + + title = models.CharField(_('Title'), max_length=128) + events_source = models.URLField(_('Events source URL')) + form_url = models.URLField(_('Application form URL')) + session_vars = JSONField(_('Session vars')) + slot_duration = models.TimeField( + _('Slot duration'), default=parse_time('00:30')) + business_hours_start = models.TimeField( + _('Business hour start'), default=parse_time('08:00')) + business_hours_end = models.TimeField( + _('Business hour end'), default=parse_time('18:00')) + event_default_title = models.CharField( + _('Event default title'), max_length=32) + + template_name = 'chrono/calendar.html' + + class Meta: + verbose_name = _('Calendar Cell') + + class Media: + js = ( + 'xstatic/lib/moment.min.js', + 'xstatic/fullcalendar.min.js', + 'chrono/chrono.js' + ) + css = {'all': ('xstatic/fullcalendar.min.css', 'chrono/chrono.css',)} + + @classmethod + def is_enabled(cls): + return is_chrono_enabled() + + def render(self, context): + include = ['slot_duration', 'business_hours_start', 'business_hours_end', 'event_default_title'] + context['chrono'] = json.dumps({key: str(value) for key, value in vars(self).items() + if key in include}) + return super(CalendarCell, self).render(context) diff --git a/combo/apps/chrono/static/chrono/chrono.css b/combo/apps/chrono/static/chrono/chrono.css new file mode 100644 index 0000000..910c7e1 --- /dev/null +++ b/combo/apps/chrono/static/chrono/chrono.css @@ -0,0 +1,95 @@ +#calendar { + width: 900px; + margin: 0 auto; +} + + +page { + max-width: 960px; padding: 0 15px; margin: 40px auto; + @media (max-height: 790px) { + margin-top: 0; + } +} +.page-header h1 { .text-center; font-weight: 100; } + +.input, select { + padding: 2px 5px; +} +.btn { + padding: .2em .8em; + border-radius: 4px; + border: 1px solid #bcbcbc; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); + background-image: linear-gradient(180deg, rgba(255,255,255,1) 0%,rgba(239,239,239,1) 60%,rgba(225,223,226,1) 100%); + background-repeat: no-repeat; // fix for firefox +} + +.bubble { + box-shadow: 0 2px 4px rgba(0,0,0,.2); border-radius: 2px; + background: #fff; padding: 15px; + width: 420px; + z-index: 99; + position: absolute; + + .close { + position: absolute; font-size: 24px; line-height: 1; + padding: 0 5px; + right: 5px; top: 5px; + } +} + +.bubble { + @border-color: #ccc; + border: 1px solid @border-color; + .arrow, .arrow:after { + position: absolute; height: 0; width: 0; font-size: 0; .horizontal-border(transparent, 10px); + } + &-top, &-bottom { + .arrow { + left: 50%; margin-left: -10px; + } + .arrow:after { + content: ''; left: -10px; + } + } + &-top { + .arrow { + border-top: @border-color 10px solid; top: 100%; + } + .arrow:after { + border-top: #FFF 10px solid; bottom: 1px; + } + } + &-bottom { + .arrow { + border-bottom: @border-color 10px solid; bottom: 100%; + } + .arrow:after { + border-bottom: #FFF 10px solid; top: 1px; + } + } +} + + +.form-group { + .clearfix; padding-bottom: 8px; + &>label { + float: left; width: 4em; text-align: right; padding-right: 5px; + } + &>input, &>.input-wrapper { + margin-left: 4em; + display: block; + } +} + + +.btn-delete { + margin-top: 5px; + display: none; + .text-danger; + &:hover { + text-decoration: underline; + } +} + +.usage { margin-top: 10px; } diff --git a/combo/apps/chrono/static/chrono/chrono.js b/combo/apps/chrono/static/chrono/chrono.js new file mode 100644 index 0000000..09b8c4a --- /dev/null +++ b/combo/apps/chrono/static/chrono/chrono.js @@ -0,0 +1,76 @@ +$(function() { // document ready + var calendar = $('#calendar').fullCalendar({ + header: { + left: 'prev,next today', + center: 'title', + right: 'agendaWeek,agendaDay' + }, + // slotDuration: chrono.slot_duration, + defaultView: 'agendaWeek', + weekends: false, + defaultTimedEventDuration: '02:00', + allDaySlot: false, + scrollTime: '08:00', + businessHours: { + start: chrono.business_hours_start, + end: chrono.business_hours_end, + }, + events: events_source_url, + eventOverlap: function(stillEvent, movingEvent) { + return true; + }, + editable: true, + selectable: true, + selectHelper: true, + select: function(start, end) { + if (start.isBefore(moment())){ + $('#calendar').fullCalendar('unselect'); + return false; + } + var title = chrono.event_default_title; + var eventData; + if (title && title.trim()) { + eventData = { + title: title, + start: start, + end: end + }; + calendar.fullCalendar('renderEvent', eventData); + } + calendar.fullCalendar('unselect'); + }, + eventRender: function(event, element) { + var start = moment(event.start).fromNow(); + element.attr('title', start); + }, + loading: function() { + }, + eventClick: function(calEvent, jsEvent, view){ + if (calEvent.source){ + console.log(calEvent.source); + return false; + } + params = { + start: calEvent.start.format(), + end: calEvent.end.format() + } + form_url = events_booking_url + '?' + $.param(params) + $.ajax({ + type: "POST", + url: events_booking_url, + data: JSON.stringify({ + 'start': calEvent.start.format(), + 'end': calEvent.end.format() + }), + dataType: 'json', + success: function(response){ + console.log(response.url); + window.location = response.url; + }, + error: function(xhr, status, error){ + console.log(JSON.stringify(status)); + } + }); + } + }); +}); diff --git a/combo/apps/chrono/templates/chrono/calendar.html b/combo/apps/chrono/templates/chrono/calendar.html new file mode 100644 index 0000000..6cb98d7 --- /dev/null +++ b/combo/apps/chrono/templates/chrono/calendar.html @@ -0,0 +1,10 @@ +

Calendar

+ + + +
+
diff --git a/combo/apps/chrono/urls.py b/combo/apps/chrono/urls.py new file mode 100644 index 0000000..33ff5a6 --- /dev/null +++ b/combo/apps/chrono/urls.py @@ -0,0 +1,24 @@ +# combo - content management system +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf.urls import url + +from .views import EventsView, BookingView + +urlpatterns = [ + url(r'^chrono/events/(?P[\w,-]+)/', EventsView.as_view(), name='chrono-events'), + url(r'^chrono/book/(?P[\w,-]+)/', BookingView.as_view(), name='chrono-booking'), +] diff --git a/combo/apps/chrono/utils.py b/combo/apps/chrono/utils.py new file mode 100644 index 0000000..e9421d2 --- /dev/null +++ b/combo/apps/chrono/utils.py @@ -0,0 +1,61 @@ +# combo - content management system +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# 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 combo.utils import requests + + +def get_chrono_service(): + if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('chrono'): + return settings.KNOWN_SERVICES['chrono'].values()[0] + + +def is_chrono_enabled(): + return get_chrono_service() + + +def convert_widget_format(events): + data = [] + for event in events.get('data'): + data.append({ + 'id': 'unselected', + 'title': event.get('text'), + 'start': event.get('datetime'), + 'editable': False, + 'durationEditable': False, + 'rendering': 'inverse-background' + }) + return data + + +def get_chrono_events(chrono_url, **kwargs): + response = requests.get( + chrono_url, + headers={'accept': 'application/json'}, + **kwargs) + data = convert_widget_format(response.json()) + return data + + +def build_session_vars(cell, data): + session_vars = {} + for key, value in cell.session_vars.items(): + key = 'session_var_%s' % key + if data.get(value): + session_vars.update({key: data[value]}) + else: + session_vars.update({key: value}) + return session_vars diff --git a/combo/apps/chrono/views.py b/combo/apps/chrono/views.py new file mode 100644 index 0000000..c4cc4b4 --- /dev/null +++ b/combo/apps/chrono/views.py @@ -0,0 +1,54 @@ +# combo - content management system +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +from django.shortcuts import get_object_or_404 +from django.http import JsonResponse, HttpResponseRedirect, HttpResponseNotAllowed +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View +from django.views.generic.detail import SingleObjectMixin + +from .models import CalendarCell +from .utils import get_chrono_events, build_session_vars + + +class EventsView(SingleObjectMixin, View): + + http_method_names = ['get'] + model = CalendarCell + + def get(self, request, *args, **kwargs): + cell = self.get_object() + data = get_chrono_events(cell.events_source) + return JsonResponse(data, safe=False) + + +class BookingView(SingleObjectMixin, View): + + http_method_names = ['post'] + model = CalendarCell + + @csrf_exempt + def dispatch(self, request, *args, **kwargs): + return super(BookingView, self).dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + data = json.loads(request.body) + cell = self.get_object() + params = build_session_vars(cell, data) + params = '&'.join(['%s=%s' % (key, value) for key, value in params.items()]) + url = '%s?%s' % (cell.form_url, params) + return JsonResponse({'url': url}, safe=False) diff --git a/combo/settings.py b/combo/settings.py index 0f0247d..f1bdfb5 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -75,8 +75,10 @@ INSTALLED_APPS = ( 'combo.apps.notifications', 'combo.apps.search', 'combo.apps.usersearch', + 'combo.apps.chrono', 'haystack', 'xstatic.pkg.chartnew_js', + 'xstatic.pkg.fullcalendar', ) INSTALLED_APPS = plugins.register_plugins_apps(INSTALLED_APPS) diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..91784f3 --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,102 @@ +import json +import urlparse + +import pytest +import mock + +from django.utils.dateparse import parse_time + +from combo.data.models import Page +from combo.apps.chrono.models import CalendarCell + +pytestmark = pytest.mark.django_db + +CHRONO_EVENTS = { + "data": [ + { + "disabled": True, + "text": "19 mai 2017 08:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/86/" + }, + "id": 86, + "datetime": "2017-05-19 08:00:00" + }, + { + "disabled": True, + "text": "19 mai 2017 08:30", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/87/" + }, + "id": 87, + "datetime": "2017-05-19 08:30:00" + }, + { + "disabled": True, + "text": "19 mai 2017 09:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/88/" + }, + "id": 88, + "datetime": "2017-05-19 09:00:00" + }, + { + "disabled": True, + "text": "19 mai 2017 09:30", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" + }, + "id": 89, + "datetime": "2017-05-19 09:30:00" + } + ] +} + + +class MockedRequestResponse(mock.Mock): + + def json(self): + return json.loads(self.content) + + +@pytest.fixture +def cell(db): + page = Page.objects.create() + cell = CalendarCell( + page=page, title='Example Of Calendar', order=1, + events_source='http://example.net/api/events/', + form_url='http://example.net/form/whatever/', + session_vars={ + "start_dt": "start", "end_dt": "end", + "whatever_slug": "whatever" + }, + slot_duration=parse_time('00:30'), + business_hours_start=parse_time('08:00'), + business_hours_end=parse_time('18:00'), + event_default_title='Available' + ) + cell.save() + return cell + + +@mock.patch('combo.apps.chrono.utils.requests.get') +def test_get_events(mocked_get, app, cell): + mocked_get.return_value = MockedRequestResponse(content=json.dumps(CHRONO_EVENTS)) + resp = app.get('/chrono/events/%s/' % cell.pk) + assert len(resp.json) == 4 + for datum in resp.json: + assert datum['id'] == 'unselected' + assert datum['rendering'] == 'inverse-background' + + +def test_redirection_session_vars(app, cell, settings): + params = { + 'start': '2017-05-19T10:30:23', + 'end': '2017-05-19T12:30:14', + } + resp = app.post_json('/chrono/book/%s/' % cell.pk, params=params) + parsed = urlparse.urlparse(resp.json['url']) + qs = urlparse.parse_qs(parsed.query) + assert qs['session_var_whatever_slug'] == ['whatever'] + assert qs['session_var_start_dt'] == [params['start']] + assert qs['session_var_end_dt'] == [params['end']] -- 2.11.0