From d816a4244913293032a3d921058e9897e3a564de 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/calendar/README | 4 + combo/apps/calendar/__init__.py | 28 +++ combo/apps/calendar/forms.py | 79 +++++++ combo/apps/calendar/migrations/0001_initial.py | 39 ++++ combo/apps/calendar/migrations/__init__.py | 0 combo/apps/calendar/models.py | 67 ++++++ .../templates/calendar/booking_calendar_cell.html | 65 ++++++ combo/apps/calendar/templatetags/__init__.py | 0 combo/apps/calendar/templatetags/calendar.py | 29 +++ combo/apps/calendar/urls.py | 23 ++ combo/apps/calendar/utils.py | 208 ++++++++++++++++++ combo/apps/calendar/views.py | 44 ++++ combo/settings.py | 1 + tests/settings.py | 14 ++ tests/test_calendar.py | 239 +++++++++++++++++++++ 15 files changed, 840 insertions(+) create mode 100644 combo/apps/calendar/README create mode 100644 combo/apps/calendar/__init__.py create mode 100644 combo/apps/calendar/forms.py create mode 100644 combo/apps/calendar/migrations/0001_initial.py create mode 100644 combo/apps/calendar/migrations/__init__.py create mode 100644 combo/apps/calendar/models.py create mode 100644 combo/apps/calendar/templates/calendar/booking_calendar_cell.html create mode 100644 combo/apps/calendar/templatetags/__init__.py create mode 100644 combo/apps/calendar/templatetags/calendar.py create mode 100644 combo/apps/calendar/urls.py create mode 100644 combo/apps/calendar/utils.py create mode 100644 combo/apps/calendar/views.py create mode 100644 tests/test_calendar.py diff --git a/combo/apps/calendar/README b/combo/apps/calendar/README new file mode 100644 index 0000000..9c206de --- /dev/null +++ b/combo/apps/calendar/README @@ -0,0 +1,4 @@ +Combo calendar cell +=================== + +To be visible, this cell needs a 'chrono' entry in settings.KNOWN_SERVICES diff --git a/combo/apps/calendar/__init__.py b/combo/apps/calendar/__init__.py new file mode 100644 index 0000000..fe39ed0 --- /dev/null +++ b/combo/apps/calendar/__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.calendar' + + def get_before_urls(self): + from . import urls + return urls.urlpatterns + + +default_app_config = 'combo.apps.calendar.AppConfig' diff --git a/combo/apps/calendar/forms.py b/combo/apps/calendar/forms.py new file mode 100644 index 0000000..13dccae --- /dev/null +++ b/combo/apps/calendar/forms.py @@ -0,0 +1,79 @@ +# 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 django.utils.translation import ugettext_lazy as _ +from django.utils.dateparse import parse_datetime, parse_time + +from .models import BookingCalendar +from .utils import get_agendas +from combo.apps.wcs.utils import get_wcs_options + + +class BookingCalendarForm(forms.ModelForm): + + class Meta: + model = BookingCalendar + fields = ( + 'title', 'agenda_reference', 'formdef_reference', + 'slot_duration', 'minimal_booking_duration') + + def __init__(self, *args, **kwargs): + super(BookingCalendarForm, self).__init__(*args, **kwargs) + agenda_references = get_agendas() + formdef_references = get_wcs_options('/api/formdefs/') + self.fields['agenda_reference'].widget = forms.Select(choices=agenda_references) + self.fields['formdef_reference'].widget = forms.Select(choices=formdef_references) + + +class BookingForm(forms.Form): + + def __init__(self, *args, **kwargs): + self.cell = kwargs.pop('cell') + super(BookingForm, self).__init__(*args, **kwargs) + self.cleaned_data = {} + + def is_valid(self): + slots = getattr(self.data, 'getlist', lambda x: [])('slots') + # check that at least one slot if selected + if not slots: + raise ValueError(_('Please select slots')) + offset = self.cell.slot_duration + start_dt = parse_datetime(slots[0]) + end_dt = parse_datetime(slots[-1]) + offset + slots.append(end_dt.isoformat()) + + # check that all slots are part of the same day + for slot in slots: + if parse_datetime(slot).date() != start_dt.date(): + raise ValueError(_('Please select slots of the same day')) + + # check that slots datetime are contiguous + start = start_dt + while start <= end_dt: + if start.isoformat() not in slots: + raise ValueError(_('Please select contiguous slots')) + start = start + offset + + # check that event booking duration >= minimal booking duration + min_duration = self.cell.minimal_booking_duration + if not (end_dt - start_dt) >= min_duration: + str_min_duration = parse_time(str(min_duration)).strftime('%H:%M') + message = _("Minimal booking duration is %s") % str_min_duration + raise ValueError(message) + self.cleaned_data['start'] = start_dt + self.cleaned_data['end'] = end_dt + return True diff --git a/combo/apps/calendar/migrations/0001_initial.py b/combo/apps/calendar/migrations/0001_initial.py new file mode 100644 index 0000000..fbb134e --- /dev/null +++ b/combo/apps/calendar/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import datetime + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0027_page_picture'), + ('auth', '0006_require_contenttypes_0002'), + ] + + operations = [ + migrations.CreateModel( + name='BookingCalendar', + 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, null=True, verbose_name='Title', blank=True)), + ('agenda_reference', models.CharField(max_length=128, verbose_name='Agenda')), + ('formdef_reference', models.CharField(max_length=128, verbose_name='Form')), + ('slot_duration', models.DurationField(default=datetime.timedelta(0, 1800), help_text='Format is hours:minutes:seconds', verbose_name='Slot duration')), + ('minimal_booking_duration', models.DurationField(default=datetime.timedelta(0, 3600), help_text='Format is hours:minutes:seconds', verbose_name='Minimal booking duration')), + ('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)), + ('page', models.ForeignKey(to='data.Page')), + ], + options={ + 'verbose_name': 'Booking Calendar', + }, + ), + ] diff --git a/combo/apps/calendar/migrations/__init__.py b/combo/apps/calendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/combo/apps/calendar/models.py b/combo/apps/calendar/models.py new file mode 100644 index 0000000..a56512f --- /dev/null +++ b/combo/apps/calendar/models.py @@ -0,0 +1,67 @@ +# 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 datetime + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger + +from combo.data.models import CellBase +from combo.data.library import register_cell_class +from .utils import is_chrono_enabled, is_wcs_enabled, get_calendar + + +@register_cell_class +class BookingCalendar(CellBase): + + title = models.CharField(_('Title'), max_length=128, blank=True, null=True) + agenda_reference = models.CharField(_('Agenda'), max_length=128) + formdef_reference = models.CharField(_('Form'), max_length=128) + slot_duration = models.DurationField( + _('Slot duration'), default=datetime.timedelta(minutes=30), + help_text=_('Format is hours:minutes:seconds')) + minimal_booking_duration = models.DurationField( + _('Minimal booking duration'), default=datetime.timedelta(hours=1), + help_text=_('Format is hours:minutes:seconds')) + + template_name = 'calendar/booking_calendar_cell.html' + + class Meta: + verbose_name = _('Booking Calendar') + + def get_default_form_class(self): + from .forms import BookingCalendarForm + return BookingCalendarForm + + @classmethod + def is_enabled(cls): + return is_chrono_enabled() and is_wcs_enabled() + + def render(self, context): + request = context['request'] + page = request.GET.get('week_%s' % self.pk, 1) + # get calendar + calendar = get_calendar(self.agenda_reference, self.slot_duration) + paginator = Paginator(calendar, 1) + try: + cal_page = paginator.page(page) + except PageNotAnInteger: + cal_page = paginator.page(1) + except (EmptyPage,): + cal_page = paginator.page(paginator.num_pages) + context['calendar'] = cal_page + return super(BookingCalendar, self).render(context) diff --git a/combo/apps/calendar/templates/calendar/booking_calendar_cell.html b/combo/apps/calendar/templates/calendar/booking_calendar_cell.html new file mode 100644 index 0000000..2d97533 --- /dev/null +++ b/combo/apps/calendar/templates/calendar/booking_calendar_cell.html @@ -0,0 +1,65 @@ +{% load i18n calendar %} + +
+ {% if cell.title %} +

{{cell.title}}

+ {% endif %} +
+ {% if calendar.has_other_pages %} +

+ {% if calendar.has_previous %} + + {% else %} + {% trans "previous week" %} + {% endif %} + + {{ calendar.number }} / {{ calendar.paginator.num_pages }} + + {% if calendar.has_next %} + + {% else %} + {% trans "next week" %} + {% endif %} +

+ {% endif %} + + {% if calendar %} +
+ {% csrf_token %} + + {% for cal in calendar %} + + + + {% for day in cal.get_days %} + + {% endfor %} + + + + {% for slot in cal.get_slots %} + + + {% for day in cal.get_days %} + {% get_day_slot cal day=day slot=slot as value %} + {% if not value.exist %} + + {% elif value.available %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + + {% endfor %} +
{{day|date:"SHORT_DATE_FORMAT"}}
{{slot|date:"TIME_FORMAT"}} + + +
+ +
+ {% endif %} +
+
diff --git a/combo/apps/calendar/templatetags/__init__.py b/combo/apps/calendar/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/combo/apps/calendar/templatetags/calendar.py b/combo/apps/calendar/templatetags/calendar.py new file mode 100644 index 0000000..7d5d6b1 --- /dev/null +++ b/combo/apps/calendar/templatetags/calendar.py @@ -0,0 +1,29 @@ +# 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 datetime + +from django import template + +register = template.Library() + + +@register.assignment_tag +def get_day_slot(cal, *args, **kwargs): + day = kwargs.get('day') + slot = kwargs.get('slot') + time_slot = datetime.datetime.combine(day, slot) + return cal.get_availability(time_slot) diff --git a/combo/apps/calendar/urls.py b/combo/apps/calendar/urls.py new file mode 100644 index 0000000..e556696 --- /dev/null +++ b/combo/apps/calendar/urls.py @@ -0,0 +1,23 @@ +# 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 BookingView + +urlpatterns = [ + url(r'^calendar/book/(?P[\w,-]+)/', BookingView.as_view(), name='calendar-booking'), +] diff --git a/combo/apps/calendar/utils.py b/combo/apps/calendar/utils.py new file mode 100644 index 0000000..55f7a3b --- /dev/null +++ b/combo/apps/calendar/utils.py @@ -0,0 +1,208 @@ +# 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 +import urlparse +import datetime + +from django.conf import settings +from django.utils.dateparse import parse_datetime + +from combo.utils import requests + + +def get_services(service_name): + if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get(service_name): + return settings.KNOWN_SERVICES[service_name] + return {} + + +def get_wcs_services(): + return get_services('wcs') + + +def get_chrono_service(): + for chrono_key, chrono_site in get_services('chrono').iteritems(): + if not chrono_site.get('secondary', True): + chrono_site['slug'] = chrono_key + return chrono_site + return {} + + +def is_chrono_enabled(): + return bool(get_chrono_service()) + + +def is_wcs_enabled(): + return bool(get_wcs_services()) + + +def get_agendas(): + chrono = get_chrono_service() + references = [] + response = requests.get('api/agenda/', remote_service=chrono, without_user=True) + try: + result = response.json() + except ValueError: + return references + for agenda in result.get('data'): + references.append(( + '%s:%s' % (chrono['slug'], agenda['slug']), agenda['text'])) + return references + + +def get_chrono_events(agenda_reference): + chrono_key, chrono_slug = agenda_reference.split(':') + chrono = get_chrono_service() + response = requests.get('api/agenda/%s/datetimes/' % chrono_slug, remote_service=chrono, without_user=True) + try: + result = response.json() + except ValueError: + return [] + return result.get('data', []) + + +def get_calendar(agenda_reference, offset): + if not agenda_reference: + return [] + events = get_chrono_events(agenda_reference) + calendar = {} + for event in events: + event_datetime = parse_datetime(event['datetime']) + week_ref = event_datetime.isocalendar()[1] + if week_ref not in calendar: + calendar[week_ref] = WeekCalendar(week_ref, offset) + week_cal = calendar[week_ref] + # add day to week calendar + if not week_cal.has_day(event_datetime.date()): + day = WeekDay(event_datetime.date()) + week_cal.days.append(day) + else: + day = week_cal.get_day(event_datetime.date()) + # add slots to day + day.add_slots(DaySlot( + event_datetime, True if not event.get('disabled', True) else False)) + + return sorted(calendar.values(), key=lambda x: x.week) + + +def get_form_url_with_params(cell, data): + session_vars = { + "session_var_booking_agenda_slug": cell.agenda_reference.split(':')[1], + "session_var_booking_start": data['start'].isoformat(), + "session_var_booking_end": data['end'].isoformat() + } + wcs_key, wcs_slug = cell.formdef_reference.split(':') + wcs = get_wcs_services().get(wcs_key) + url = urlparse.urljoin(wcs['url'], wcs_slug) + url += '/?%s' % urllib.urlencode(session_vars) + return url + + +class DaySlot(object): + + def __init__(self, date_time, available, exist=True): + self.date_time = date_time + self.available = available + self.exist = exist + + def __repr__(self): + return '' % (self.date_time.isoformat(), self.available) + + @property + def label(self): + return '%s' % self.date_time.isoformat() + + +class WeekDay(object): + + def __init__(self, date): + self.date = date + self.slots = [] + + def __repr__(self): + return '' % self.date.isoformat() + + def add_slots(self, slot): + if slot not in self.slots: + self.slots.append(slot) + + def get_slot(self, slot_time): + for slot in self.slots: + if slot.date_time.time() == slot_time: + return slot + slot_datetime = datetime.datetime.combine(self.date, slot_time) + return DaySlot(slot_datetime, False, exist=False) + + def get_minimum_slot(self): + return min(self.slots, key=lambda x: x.date_time.time()) + + def get_maximum_slot(self): + return max(self.slots, key=lambda x: x.date_time.time()) + + def is_available(self, slot_time): + return self.get_slot(slot_time).available + + +class WeekCalendar(object): + + def __init__(self, week, offset): + self.week = week + self.offset = offset + self.days = [] + + def __repr__(self): + return '' % self.week + + def get_slots(self): + start = self.get_minimum_slot() + end = self.get_maximum_slot() + while start <= end: + yield start + start = datetime.datetime.combine( + datetime.date.today(), start) + self.offset + start = start.time() + + def get_days(self): + if self.days: + base_day = self.days[0].date + else: + base_day = datetime.datetime.today() + # only week days + for index in range(0, 5): + day = base_day + datetime.timedelta(days=index - base_day.weekday()) + yield day + + def get_day(self, date): + for day in self.days: + if day.date == date: + return day + return None + + def has_day(self, date): + return bool(self.get_day(date)) + + def get_availability(self, slot): + if not self.has_day(slot.date()): + return DaySlot(slot, False, exist=False) + day = self.get_day(slot.date()) + return day.get_slot(slot.time()) + + def get_minimum_slot(self): + return min([day.get_minimum_slot().date_time.time() for day in self.days]) + + def get_maximum_slot(self): + return max([day.get_maximum_slot().date_time.time() for day in self.days]) diff --git a/combo/apps/calendar/views.py b/combo/apps/calendar/views.py new file mode 100644 index 0000000..ea6aa7a --- /dev/null +++ b/combo/apps/calendar/views.py @@ -0,0 +1,44 @@ +# 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.http import HttpResponseRedirect +from django.views.generic import View +from django.views.generic.detail import SingleObjectMixin +from django.contrib import messages + +from .models import BookingCalendar +from .utils import get_form_url_with_params +from .forms import BookingForm + + +class BookingView(SingleObjectMixin, View): + + http_method_names = ['post'] + model = BookingCalendar + + def post(self, request, *args, **kwargs): + cell = self.get_object() + form = BookingForm(request.POST, cell=cell) + try: + form.is_valid() + except ValueError as exc: + messages.error(request, exc.message) + redirect_url = '%s?%s' % ( + cell.page.get_online_url(), request.GET.urlencode()) + return HttpResponseRedirect(redirect_url) + data = form.cleaned_data + url = get_form_url_with_params(cell, data) + return HttpResponseRedirect(url) diff --git a/combo/settings.py b/combo/settings.py index 778b6f0..d0c1e5f 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -78,6 +78,7 @@ INSTALLED_APPS = ( 'combo.apps.search', 'combo.apps.usersearch', 'combo.apps.maps', + 'combo.apps.calendar', 'haystack', 'xstatic.pkg.chartnew_js', 'xstatic.pkg.leaflet', diff --git a/tests/settings.py b/tests/settings.py index 2ebafa2..2ad26f6 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -13,6 +13,20 @@ KNOWN_SERVICES = { 'default': {'title': 'test', 'url': 'http://example.org', 'secret': 'combo', 'orig': 'combo', 'backoffice-menu-url': 'http://example.org/manage/',} + }, + 'chrono': { + 'default': { + 'title': 'test', 'url': 'http://chrono.example.org', + 'secret': 'combo', 'orig': 'combo', + 'backoffice-menu-url': 'http://chrono.example.org/manage/', + 'secondary': False, + }, + 'other': { + 'title': 'other', 'url': 'http://other.chrono.example.org', + 'secret': 'combo', 'orig': 'combo', + 'backoffice-menu-url': 'http://other.chrono.example.org/manage/', + 'secondary': True, + } } } diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..5629bf4 --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,239 @@ +import json +import urlparse +import datetime + +import pytest +import mock + +from django.utils.dateparse import parse_time +from django.contrib.auth.models import User + +from combo.data.models import Page +from combo.apps.calendar.models import BookingCalendar +from combo.apps.calendar.utils import get_calendar, get_chrono_service +pytestmark = pytest.mark.django_db + +CHRONO_EVENTS = { + "data": [ + { + "disabled": False, + "text": "13 juin 2017 08:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/86/" + }, + "id": 86, + "datetime": "2017-06-13 08:00:00" + }, + { + "disabled": False, + "text": "13 juin 2017 08:30", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/87/" + }, + "id": 87, + "datetime": "2017-06-13 08:30:00" + }, + { + "disabled": False, + "text": "13 juin 2017 09:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/88/" + }, + "id": 88, + "datetime": "2017-06-13 09:00:00" + }, + { + "disabled": False, + "text": "13 juin 2017 09:30", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" + }, + "id": 89, + "datetime": "2017-06-13 09:30:00" + }, + { + "disabled": False, + "text": "14 juin 2017 09:30", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" + }, + "id": 90, + "datetime": "2017-06-14 09:30:00" + }, + { + "disabled": False, + "text": "14 juin 2017 10:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" + }, + "id": 91, + "datetime": "2017-06-14 10:00:00" + }, + { + "disabled": True, + "text": "14 juin 2017 15:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" + }, + "id": 91, + "datetime": "2017-06-14 15:00:00" + }, + { + "disabled": True, + "text": "15 juin 2017 10:00", + "api": { + "fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" + }, + "id": 92, + "datetime": "2017-06-15 10:00:00" + } + + + ] +} + + +WCS_FORMDEFS = [ + { + "count": 12, + "category": "common", + "functions": { + "_receiver": { + "label": "Recipient" + }, + }, + "authentication_required": False, + "description": "", + "title": "Demande de place en creche", + "url": "http://example.net/demande-de-place-en-creche/", + "category_slug": "common", + "redirection": False, + "keywords": [], + "slug": "demande-de-place-en-creche" + } +] + + +def login(app, username='admin', password='admin'): + login_page = app.get('/login/') + login_form = login_page.forms[0] + login_form['username'] = username + login_form['password'] = password + resp = login_form.submit() + assert resp.status_int == 302 + return app + + +def str2datetime(sdt): + return datetime.datetime.strptime(sdt, '%Y-%m-%dT%H:%M:%S') + + +class MockedRequestResponse(mock.Mock): + + def json(self): + return json.loads(self.content) + + +def mocked_requests_get(*args, **kwargs): + remote_service = kwargs.get('remote_service') + if 'chrono' in remote_service['url']: + return MockedRequestResponse( + content=json.dumps(CHRONO_EVENTS)) + else: + return MockedRequestResponse( + content=json.dumps(WCS_FORMDEFS)) + + +@pytest.fixture +def admin(db): + return User.objects.create_superuser(username='admin', password='admin', email=None) + + +@pytest.fixture +def anonymous(app): + return app + + +@pytest.fixture +def connected(app, admin): + return login(app) + + +@pytest.fixture(params=['anonymous', 'connected']) +def client(request, anonymous, connected): + return locals().get(request.param) + + +@pytest.fixture +def cell(db): + page = Page.objects.create(title='whatever', slug='booking', template_name='standard') + cell = BookingCalendar( + page=page, title='Example Of Calendar', + agenda_reference='default:test', + formdef_reference='default:test', + slot_duration=datetime.timedelta(minutes=30), + minimal_booking_duration=datetime.timedelta(hours=1), + placeholder='content', order=0 + ) + cell.save() + return cell + + +def test_get_chrono_service(settings): + service = get_chrono_service() + assert service['title'] == 'test' + assert service['url'] == 'http://chrono.example.org' + assert service['secondary'] is False + + +@mock.patch('combo.apps.calendar.utils.requests.get', side_effect=mocked_requests_get) +def test_cell_rendering(mocked_get, client, cell): + page = client.get('/booking/') + # test without selecting slots + resp = page.form.submit().follow() + assert 'Please select slots' in resp.content + # test with slots from different day + resp.form.set('slots', True, 0) + resp.form.set('slots', True, 1) + resp.form.set('slots', True, 4) + resp = resp.form.submit().follow() + # test with non contiguous slots + assert 'Please select slots of the same day' in resp.content + resp.form.set('slots', True, 0) + resp.form.set('slots', True, 2) + resp = resp.form.submit().follow() + assert 'Please select contiguous slots' in resp.content + # test with invalid booking duration + resp.form.set('slots', True, 0) + resp = resp.form.submit().follow() + assert 'Minimal booking duration is 01:00' in resp.content + # test with valid selected slots + resp.form.set('slots', True, 0) + resp.form.set('slots', True, 1) + resp.form.set('slots', True, 2) + resp = resp.form.submit() + parsed = urlparse.urlparse(resp.url) + assert parsed.path == '/test/' + qs = urlparse.parse_qs(parsed.query) + assert qs['session_var_booking_agenda_slug'] == ['test'] + assert qs['session_var_booking_start'] == ['2017-06-13T08:00:00'] + assert qs['session_var_booking_end'] == ['2017-06-13T09:30:00'] + + +@mock.patch('combo.apps.calendar.utils.requests.get', side_effect=mocked_requests_get) +def test_calendar(mocked_get, cell): + cal = get_calendar('default:whatever', cell.slot_duration)[0] + assert len(cal.days) == 3 + for day in cal.get_days(): + assert day in [ + str2datetime('2017-06-12T08:00:00').date() + datetime.timedelta(days=i) + for i in range(0, 5)] + min_slot = str2datetime('2017-06-13T08:00:00') + max_slot = str2datetime('2017-06-14T15:00:00') + for slot in cal.get_slots(): + assert (min_slot.time() <= slot <= max_slot.time()) is True + assert cal.has_day(min_slot.date()) is True + assert cal.get_availability(str2datetime('2017-06-14T15:00:00')).available is False + assert cal.get_minimum_slot() == min_slot.time() + assert cal.get_maximum_slot() == max_slot.time() + assert cal.get_day(max_slot.date()).slots[-1].available is False -- 2.11.0