From 89985a0175abd22e4658bdd1ca6dfc4f65dd338f 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 | 18 ++++ combo/apps/chrono/__init__.py | 28 +++++++ combo/apps/chrono/forms.py | 58 +++++++++++++ combo/apps/chrono/migrations/0001_initial.py | 43 ++++++++++ combo/apps/chrono/migrations/__init__.py | 0 combo/apps/chrono/models.py | 71 ++++++++++++++++ combo/apps/chrono/static/chrono/css/calendar.css | 23 +++++ combo/apps/chrono/static/chrono/js/calendar.js | 12 +++ combo/apps/chrono/templates/chrono/calendar.html | 11 +++ combo/apps/chrono/urls.py | 24 ++++++ combo/apps/chrono/utils.py | 93 +++++++++++++++++++++ combo/apps/chrono/views.py | 57 +++++++++++++ combo/settings.py | 2 + debian/control | 1 + setup.py | 1 + tests/test_calendar.py | 102 +++++++++++++++++++++++ 16 files changed, 544 insertions(+) create mode 100644 combo/apps/chrono/README create mode 100644 combo/apps/chrono/__init__.py create mode 100644 combo/apps/chrono/forms.py create mode 100644 combo/apps/chrono/migrations/0001_initial.py create mode 100644 combo/apps/chrono/migrations/__init__.py create mode 100644 combo/apps/chrono/models.py create mode 100644 combo/apps/chrono/static/chrono/css/calendar.css create mode 100644 combo/apps/chrono/static/chrono/js/calendar.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..c4f0bf0 --- /dev/null +++ b/combo/apps/chrono/README @@ -0,0 +1,18 @@ +Combo calendar cell +================= + +To be visible, this cell needs a 'chrono' entry in settings.KNOWN_SERVICES + +Example of session_var definition + +{ + "nusery_id": "southpark", + "date_debut": "$start", + "date_fin": "$end" +} + +$start and $end will be replaced by event start_datetime and end_datetime +and all keys in the session_vars dick will be prefixed by a . +The result will be: + +session_var_nursery_id=southapark&dsession_var_date_debut=2017-05-19T13:30:00&session_var_date_fin=2017-05-19T15:30:00 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/forms.py b/combo/apps/chrono/forms.py new file mode 100644 index 0000000..a3a96fd --- /dev/null +++ b/combo/apps/chrono/forms.py @@ -0,0 +1,58 @@ +# 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 import forms + +from .models import CalendarCell +from .utils import get_calendar, get_agendas, get_formsdef + + +class CalendarCellForm(forms.ModelForm): + class Meta: + model = CalendarCell + fields = ( + 'title', 'agenda_reference', 'formdef_reference', + 'session_vars', 'slot_duration', 'minimal_event_duration', + 'business_hour_start', 'business_hour_start') + + def __init__(self, *args, **kwargs): + super(CalendarCellForm, self).__init__(*args, **kwargs) + agenda_references = get_agendas() + formdef_references = get_formsdef() + print(formdef_references) + self.fields['agenda_reference'].widget = forms.Select(choices=agenda_references) + self.fields['formdef_reference'].widget = forms.Select(choices=formdef_references) + + +class CalenderForm(forms.Form): + + def __init__(self, *args, **kwargs): + available_slots = kwargs.pop('available_slots') + super(CalenderForm, self).__init__(*args, **kwargs) + self.cleaned_data = {} + calendar = get_calendar(available_slots) + for index, (day, choices) in enumerate(calendar.iteritems()): + self.fields['day_%s' % index] = forms.MultipleChoiceField( + label=day, + widget=forms.CheckboxSelectMultiple(), + required=False, choices=choices) + + def as_div(self): + return self._html_output( + normal_row='
%(label)s %(field)s%(help_text)s

', + error_row='%s', row_ender='', + help_text_html=' %s', + errors_on_separate_row=True) diff --git a/combo/apps/chrono/migrations/0001_initial.py b/combo/apps/chrono/migrations/0001_initial.py new file mode 100644 index 0000000..73e6a1a --- /dev/null +++ b/combo/apps/chrono/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0006_require_contenttypes_0002'), + ('data', '0025_jsoncell_varnames_str'), + ] + + operations = [ + migrations.CreateModel( + name='CalendarCell', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('placeholder', models.CharField(max_length=20)), + ('order', models.PositiveIntegerField()), + ('slug', models.SlugField(verbose_name='Slug', blank=True)), + ('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)), + ('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(max_length=128, verbose_name='Title')), + ('agenda_reference', models.URLField(verbose_name='Events source URL')), + ('formdef_reference', models.URLField(verbose_name='Application form URL')), + ('session_vars', jsonfield.fields.JSONField(default=dict, verbose_name='Session vars')), + ('slot_duration', models.TimeField(default=datetime.time(0, 30), verbose_name='Slot duration')), + ('minimal_event_duration', models.TimeField(default=datetime.time(2, 0), verbose_name='Minimal event duration')), + ('business_hour_start', models.TimeField(default=datetime.time(8, 0), verbose_name='Business hour start')), + ('business_hour_end', models.TimeField(default=datetime.time(18, 0), verbose_name='Business hour end')), + ('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)), + ('page', models.ForeignKey(to='data.Page')), + ], + options={ + 'verbose_name': 'Calendar Cell', + }, + ), + ] diff --git a/combo/apps/chrono/migrations/__init__.py b/combo/apps/chrono/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/combo/apps/chrono/models.py b/combo/apps/chrono/models.py new file mode 100644 index 0000000..4a46e9f --- /dev/null +++ b/combo/apps/chrono/models.py @@ -0,0 +1,71 @@ +# 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.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils.dateparse import parse_time + +from combo.data.models import CellBase +from combo.data.library import register_cell_class +from .utils import is_chrono_enabled, get_chrono_events + +from jsonfield import JSONField + + +@register_cell_class +class CalendarCell(CellBase): + + title = models.CharField(_('Title'), max_length=128) + agenda_reference = models.URLField(_('Events source URL')) + formdef_reference = models.URLField(_('Application form URL')) + session_vars = JSONField(_('Session vars')) + slot_duration = models.TimeField( + _('Slot duration'), default=parse_time('00:30')) + minimal_event_duration = models.TimeField( + _('Minimal event duration'), default=parse_time('02:00')) + business_hour_start = models.TimeField( + _('Business hour start'), default=parse_time('08:00')) + business_hour_end = models.TimeField( + _('Business hour end'), default=parse_time('18:00')) + + template_name = 'chrono/calendar.html' + + class Meta: + verbose_name = _('Calendar Cell') + + def get_default_form_class(self): + from .forms import CalendarCellForm + return CalendarCellForm + + class Media: + js = ( + 'chrono/js/calendar.js' + ) + css = {'all': ('chrono/css/calendar.css',)} + + @classmethod + def is_enabled(cls): + return is_chrono_enabled() + + def save(self, *args, **kwargs): + return super(CalendarCell, self).save(*args, **kwargs) + + def render(self, context): + from .forms import CalenderForm + form = CalenderForm( + available_slots=get_chrono_events(self.agenda_reference)) + context['form'] = form + return super(CalendarCell, self).render(context) diff --git a/combo/apps/chrono/static/chrono/css/calendar.css b/combo/apps/chrono/static/chrono/css/calendar.css new file mode 100644 index 0000000..c996ca3 --- /dev/null +++ b/combo/apps/chrono/static/chrono/css/calendar.css @@ -0,0 +1,23 @@ +div.calendarcell div { + float: left; +} + +div.calendarcell input[type=checkbox]{ + display: none; +} + +div.calendarcell li { + list-style-type: none; + background-color: #ccc; + margin: 5px; + padding: 5px 5px; +} + +div.calendarcell ul { + margin: 0; + padding: 0; +} + +div,calendarcell input[checked]{ + +} diff --git a/combo/apps/chrono/static/chrono/js/calendar.js b/combo/apps/chrono/static/chrono/js/calendar.js new file mode 100644 index 0000000..7b784d2 --- /dev/null +++ b/combo/apps/chrono/static/chrono/js/calendar.js @@ -0,0 +1,12 @@ +$(function(){ + // check if datetime are part of the same day + var datetimes = $('input:checked') + + var date = datetimes.first().attr('name'); + $('input:checked').each(function(index, value){ + if ($(this).name != date){ + // unchecked + $(this).prop('checked', false); + } + }); +}); diff --git a/combo/apps/chrono/templates/chrono/calendar.html b/combo/apps/chrono/templates/chrono/calendar.html new file mode 100644 index 0000000..b903eb2 --- /dev/null +++ b/combo/apps/chrono/templates/chrono/calendar.html @@ -0,0 +1,11 @@ +{% load i18n %} + +

{{title}}

+ +
+
+ {% csrf_token %} + {{ form.as_div }} + +
+
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..33bb8a3 --- /dev/null +++ b/combo/apps/chrono/utils.py @@ -0,0 +1,93 @@ +# 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 django.utils.dateparse import parse_datetime +from django.utils import formats + + +from combo.utils import requests +from combo.apps.wcs.utils import get_wcs_json + + +def get_service(service_name): + if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get(service_name): + return settings.KNOWN_SERVICES[service_name].values()[0] + + +def get_chrono_service(): + return get_service('chrono') + + +def get_wcs_service(): + return get_service('wcs') + + +def is_chrono_enabled(): + return get_chrono_service() + + +def get_agendas(): + chrono = get_chrono_service() + url = chrono['url'] + 'api/agenda/' + response = requests.get(url, headers={'accept': 'application/json'}) + data = response.json() + agendas = [(item['api']['datetimes_url'], item['text']) for item in data['data']] + return agendas + + +def get_formsdef(): + wcs = get_wcs_service() + url = wcs['url'] + '/api/formdefs/' + response = requests.get(url, headers={'accept': 'application/json'}) + data = response.json() + forms = [(item['url'], item['title']) for item in data] + return forms + + +def get_chrono_events(chrono_url, **kwargs): + response = requests.get( + chrono_url, + headers={'accept': 'application/json'}, + **kwargs) + return response.json().get('data', []) + + +def get_calendar(events): + calendar = {} + for event in events: + day = formats.date_format(parse_datetime(event['datetime']).date()) + if day not in calendar: + calendar[day] = [] + event_datetime = parse_datetime(event['datetime']) + calendar[day].append((event_datetime.isoformat(), event_datetime.strftime('%H:%M'))) + return calendar + + +def humanize_date(datetime): + return formats.date_format( + parse_datetime(datetime).date()) + + +def build_session_vars(cell, data): + session_vars = {} + for key, value in cell.session_vars.items(): + key = 'session_var_%s' % key + value = value.strip('$') + 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..7896ddc --- /dev/null +++ b/combo/apps/chrono/views.py @@ -0,0 +1,57 @@ +# 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 urllib + +from django.http import HttpResponseRedirect, JsonResponse +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): + cell = self.get_object() + data = [request.POST.getlist(key) for key in request.POST if key != 'csrfmiddlewaretoken'] + data = { + 'start': data[0][0], + 'end': data[0][-1] + } + params = build_session_vars(cell, data) + url = '%s?%s' % (cell.formdef_reference, urllib.urlencode(params)) + return HttpResponseRedirect(url) 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/debian/control b/debian/control index 1ef3ef8..a1cfa59 100644 --- a/debian/control +++ b/debian/control @@ -18,6 +18,7 @@ Depends: ${misc:Depends}, ${python:Depends}, python-xstatic-chartnew-js, python-eopayment (>= 1.9), python-django-haystack (>= 2.4.0) + python-xstatic-fullcalendar (>= 3.4.0) Recommends: python-django-mellon, python-whoosh Conflicts: python-lingo Description: Portal Management System (Python module) diff --git a/setup.py b/setup.py index 6a7351e..e484716 100644 --- a/setup.py +++ b/setup.py @@ -116,6 +116,7 @@ setup( 'djangorestframework>=3.3, <3.4', 'django-haystack', 'whoosh', + 'XStatic-fullcalendar>=3.4.0' ], zip_safe=False, cmdclass={ diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..d3f9369 --- /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'] == 'available' + 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