0001-add-calendar-cell-model-16393.patch
combo/apps/calendar/README | ||
---|---|---|
1 |
Combo calendar cell |
|
2 |
=================== |
|
3 | ||
4 |
To be visible, this cell needs a 'chrono' entry in settings.KNOWN_SERVICES |
combo/apps/calendar/__init__.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import django.apps |
|
18 | ||
19 | ||
20 |
class AppConfig(django.apps.AppConfig): |
|
21 |
name = 'combo.apps.calendar' |
|
22 | ||
23 |
def get_before_urls(self): |
|
24 |
from . import urls |
|
25 |
return urls.urlpatterns |
|
26 | ||
27 | ||
28 |
default_app_config = 'combo.apps.calendar.AppConfig' |
combo/apps/calendar/forms.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import datetime |
|
18 | ||
19 |
from django import forms |
|
20 |
from django.utils.translation import ugettext_lazy as _ |
|
21 |
from django.utils.dateparse import parse_datetime |
|
22 | ||
23 |
from .models import BookingCalendar |
|
24 |
from .utils import get_agendas, get_formdefs |
|
25 | ||
26 | ||
27 |
class BookingCalendarForm(forms.ModelForm): |
|
28 | ||
29 |
class Meta: |
|
30 |
model = BookingCalendar |
|
31 |
fields = ( |
|
32 |
'title', 'agenda_reference', 'formdef_reference', |
|
33 |
'formdef_url_params', 'slot_duration', 'minimal_event_duration') |
|
34 | ||
35 |
def __init__(self, *args, **kwargs): |
|
36 |
super(BookingCalendarForm, self).__init__(*args, **kwargs) |
|
37 |
agenda_references = get_agendas() |
|
38 |
formdef_references = get_formdefs() |
|
39 |
self.fields['agenda_reference'].widget = forms.Select(choices=agenda_references) |
|
40 |
self.fields['formdef_reference'].widget = forms.Select(choices=formdef_references) |
|
41 | ||
42 | ||
43 |
class BookingForm(forms.Form): |
|
44 | ||
45 |
def __init__(self, *args, **kwargs): |
|
46 |
self.cell = kwargs.pop('cell') |
|
47 |
super(BookingForm, self).__init__(*args, **kwargs) |
|
48 |
self.cleaned_data = {} |
|
49 | ||
50 |
def is_valid(self): |
|
51 |
slots = getattr(self.data, 'getlist', lambda x: [])('slots') |
|
52 |
# check that at least one slot if selected |
|
53 |
if not slots: |
|
54 |
raise ValueError(_('Please select slots')) |
|
55 |
offset = self.cell.slot_duration.hour * 60 + self.cell.slot_duration.minute |
|
56 |
start_dt = parse_datetime(slots[0]) |
|
57 |
end_dt = parse_datetime(slots[-1]) + datetime.timedelta(minutes=offset) |
|
58 |
slots.append(end_dt.isoformat()) |
|
59 | ||
60 |
# check that all slots are part of the same day |
|
61 |
for slot in slots: |
|
62 |
if parse_datetime(slot).date() != start_dt.date(): |
|
63 |
raise ValueError(_('Please select slots of the same day')) |
|
64 | ||
65 |
# check that slots datetime are contiguous |
|
66 |
start = start_dt |
|
67 |
while start <= end_dt: |
|
68 |
if start.isoformat() not in slots: |
|
69 |
raise ValueError(_('Please select contiguous slots')) |
|
70 |
start = start + datetime.timedelta(minutes=offset) |
|
71 | ||
72 |
# check that event booking duration >= minimal booking duration |
|
73 |
min_duration = self.cell.minimal_event_duration.hour * 60 + self.cell.minimal_event_duration.minute |
|
74 |
if not (end_dt - start_dt) >= datetime.timedelta(minutes=min_duration): |
|
75 |
raise ValueError(_( |
|
76 |
'Minimal booking duration is %s' % self.cell.minimal_event_duration.strftime('%H:%M'))) |
|
77 |
self.cleaned_data['start'] = start_dt |
|
78 |
self.cleaned_data['end'] = end_dt |
|
79 |
return True |
combo/apps/calendar/migrations/0001_initial.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
from __future__ import unicode_literals |
|
3 | ||
4 |
from django.db import migrations, models |
|
5 |
import jsonfield.fields |
|
6 |
import datetime |
|
7 | ||
8 | ||
9 |
class Migration(migrations.Migration): |
|
10 | ||
11 |
dependencies = [ |
|
12 |
('data', '0027_page_picture'), |
|
13 |
('auth', '0006_require_contenttypes_0002'), |
|
14 |
] |
|
15 | ||
16 |
operations = [ |
|
17 |
migrations.CreateModel( |
|
18 |
name='BookingCalendar', |
|
19 |
fields=[ |
|
20 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
21 |
('placeholder', models.CharField(max_length=20)), |
|
22 |
('order', models.PositiveIntegerField()), |
|
23 |
('slug', models.SlugField(verbose_name='Slug', blank=True)), |
|
24 |
('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)), |
|
25 |
('public', models.BooleanField(default=True, verbose_name='Public')), |
|
26 |
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')), |
|
27 |
('last_update_timestamp', models.DateTimeField(auto_now=True)), |
|
28 |
('title', models.CharField(max_length=128, null=True, verbose_name='Title', blank=True)), |
|
29 |
('agenda_reference', models.CharField(max_length=128, verbose_name='Agenda')), |
|
30 |
('formdef_reference', models.CharField(max_length=128, verbose_name='Form')), |
|
31 |
('formdef_url_params', jsonfield.fields.JSONField(default={}, help_text="i.e {'session_var_toto' : 'toto'}", verbose_name='Session vars')), |
|
32 |
('slot_duration', models.TimeField(default=datetime.time(0, 30), verbose_name='Slot duration')), |
|
33 |
('minimal_event_duration', models.TimeField(default=datetime.time(1, 0), verbose_name='Minimal event duration')), |
|
34 |
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)), |
|
35 |
('page', models.ForeignKey(to='data.Page')), |
|
36 |
], |
|
37 |
options={ |
|
38 |
'verbose_name': 'Booking Calendar', |
|
39 |
}, |
|
40 |
), |
|
41 |
] |
combo/apps/calendar/models.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.db import models |
|
18 |
from django.utils.translation import ugettext_lazy as _ |
|
19 |
from django.utils.dateparse import parse_time |
|
20 |
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger |
|
21 | ||
22 |
from combo.data.models import CellBase |
|
23 |
from combo.data.library import register_cell_class |
|
24 |
from .utils import is_chrono_enabled, is_wcs_enabled, get_calendar |
|
25 | ||
26 |
from jsonfield import JSONField |
|
27 | ||
28 | ||
29 |
@register_cell_class |
|
30 |
class BookingCalendar(CellBase): |
|
31 | ||
32 |
title = models.CharField(_('Title'), max_length=128, blank=True, null=True) |
|
33 |
agenda_reference = models.CharField(_('Agenda'), max_length=128) |
|
34 |
formdef_reference = models.CharField(_('Form'), max_length=128) |
|
35 |
formdef_url_params = JSONField( |
|
36 |
_('Session vars'), |
|
37 |
help_text=_("i.e {'session_var_toto' : 'toto'}"), |
|
38 |
default={}) |
|
39 |
slot_duration = models.TimeField( |
|
40 |
_('Slot duration'), default=parse_time('00:30')) |
|
41 |
minimal_event_duration = models.TimeField( |
|
42 |
_('Minimal event duration'), default=parse_time('01:00')) |
|
43 | ||
44 |
template_name = 'calendar/booking_calendar_cell.html' |
|
45 | ||
46 |
class Meta: |
|
47 |
verbose_name = _('Booking Calendar') |
|
48 | ||
49 |
def get_default_form_class(self): |
|
50 |
from .forms import BookingCalendarForm |
|
51 |
return BookingCalendarForm |
|
52 | ||
53 |
@classmethod |
|
54 |
def is_enabled(cls): |
|
55 |
return is_chrono_enabled() and is_wcs_enabled() |
|
56 | ||
57 |
def get_calendar(self): |
|
58 |
return get_calendar(self.agenda_reference, self.slot_duration) |
|
59 | ||
60 |
def render(self, context): |
|
61 |
request = context['request'] |
|
62 |
page = request.GET.get('week_%s' % self.pk, 1) |
|
63 |
# get calendar |
|
64 |
calendar = self.get_calendar() |
|
65 |
paginator = Paginator(calendar, 1) |
|
66 |
try: |
|
67 |
cal_page = paginator.page(page) |
|
68 |
except PageNotAnInteger: |
|
69 |
cal_page = paginator.page(1) |
|
70 |
except (EmptyPage,): |
|
71 |
cal_page = paginator.page(paginator.num_pages) |
|
72 |
context['calendar'] = cal_page |
|
73 |
return super(BookingCalendar, self).render(context) |
combo/apps/calendar/templates/calendar/booking_calendar_cell.html | ||
---|---|---|
1 |
{% load i18n calendar %} |
|
2 | ||
3 |
<div id="cal-{{cell.pk}}"> |
|
4 |
{% if cell.title %} |
|
5 |
<h2>{{cell.title}}</h2> |
|
6 |
{% endif %} |
|
7 |
<div> |
|
8 |
{% if calendar.has_other_pages %} |
|
9 |
<p class="paginator"> |
|
10 |
{% if calendar.has_previous %} |
|
11 |
<a href="?week_{{cell.pk}}={{ calendar.previous_page_number }}#cal-{{cell.pk}}">{% trans "previous week" %}</a> |
|
12 |
{% else %} |
|
13 |
<span><<</span> |
|
14 |
{% endif %} |
|
15 |
|
|
16 |
<span class="current"> |
|
17 |
{{ calendar.number }} / {{ calendar.paginator.num_pages }} |
|
18 |
</span> |
|
19 |
|
|
20 |
{% if calendar.has_next %} |
|
21 |
<a href="?week_{{cell.pk}}={{ calendar.next_page_number }}#cal-{{cell.pk}}">{% trans "next week" %}</a> |
|
22 |
{% else %} |
|
23 |
<span>>></span> |
|
24 |
{% endif %} |
|
25 |
{% endif %} |
|
26 |
</div> |
|
27 | ||
28 |
<div> |
|
29 |
<form method="POST" action="{% url 'calendar-booking' cell.pk %}"> |
|
30 |
{% csrf_token %} |
|
31 |
<table id="cal-table-{{cell.pk}}"> |
|
32 |
{% for cal in calendar %} |
|
33 |
<thead> |
|
34 |
<tr> |
|
35 |
<th></th> |
|
36 |
{% for day in cal.get_days %} |
|
37 |
<th>{{day|date:"SHORT_DATE_FORMAT"}}</th> |
|
38 |
{% endfor %} |
|
39 |
</tr> |
|
40 |
</thead> |
|
41 |
<tbody> |
|
42 |
{% for slot in cal.get_slots %} |
|
43 |
<tr> |
|
44 |
<th>{{slot|date:"TIME_FORMAT"}}</th> |
|
45 |
{% for day in cal.get_days %} |
|
46 |
<td> |
|
47 |
{% cal_slot_available cal day=day slot=slot as value %} |
|
48 |
{% if value.available %} |
|
49 |
<input type="checkbox" name="slots" value="{{value.label}}" id="{{value.label}}"/> |
|
50 |
{% endif %} |
|
51 |
</td> |
|
52 |
{% endfor %} |
|
53 |
</tr> |
|
54 |
{% endfor %} |
|
55 |
</tbody> |
|
56 |
{% endfor %} |
|
57 |
</table> |
|
58 |
<input type="submit" value="{% trans "Book" %}"> |
|
59 |
</form> |
|
60 |
</div> |
|
61 |
</div> |
combo/apps/calendar/templatetags/calendar.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import datetime |
|
18 | ||
19 |
from django import template |
|
20 | ||
21 |
register = template.Library() |
|
22 | ||
23 | ||
24 |
@register.assignment_tag |
|
25 |
def cal_slot_available(cal, *args, **kwargs): |
|
26 |
day = kwargs.get('day') |
|
27 |
slot = kwargs.get('slot') |
|
28 |
time_slot = datetime.datetime.combine(day, slot) |
|
29 |
return cal.get_availability(time_slot) |
combo/apps/calendar/urls.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.conf.urls import url |
|
18 | ||
19 |
from .views import BookingView |
|
20 | ||
21 |
urlpatterns = [ |
|
22 |
url(r'^calendar/book/(?P<pk>[\w,-]+)/', BookingView.as_view(), name='calendar-booking'), |
|
23 |
] |
combo/apps/calendar/utils.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import json |
|
18 |
import urllib |
|
19 |
import datetime |
|
20 | ||
21 |
from django.conf import settings |
|
22 |
from django.utils.dateparse import parse_datetime |
|
23 |
from django.template import Context, Template |
|
24 | ||
25 |
from combo.utils import requests |
|
26 | ||
27 | ||
28 |
def get_services(service_name, key=None): |
|
29 |
if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get(service_name): |
|
30 |
services = settings.KNOWN_SERVICES[service_name] |
|
31 |
return services |
|
32 | ||
33 | ||
34 |
def get_service(service_name, key): |
|
35 |
service = get_services(service_name) |
|
36 |
return service.get(key, {}) |
|
37 | ||
38 | ||
39 |
def is_chrono_enabled(): |
|
40 |
return bool(get_services('chrono')) |
|
41 | ||
42 | ||
43 |
def is_wcs_enabled(): |
|
44 |
return bool(get_services('wcs')) |
|
45 | ||
46 | ||
47 |
def get_agendas(): |
|
48 |
chronos = get_services('chrono') |
|
49 |
references = [] |
|
50 |
for chrono_key, chrono_site in chronos.items(): |
|
51 |
response = requests.get('api/agenda/', remote_service=chrono_site) |
|
52 |
try: |
|
53 |
result = response.json() |
|
54 |
except ValueError: |
|
55 |
return references |
|
56 |
for agenda in result['data']: |
|
57 |
references.append(( |
|
58 |
'%s:%s' % (chrono_key, agenda['slug']), agenda['text'])) |
|
59 |
return references |
|
60 | ||
61 | ||
62 |
def get_formdefs(): |
|
63 |
wcs = get_services('wcs') |
|
64 |
references = [] |
|
65 |
for wcs_key, wcs_site in wcs.items(): |
|
66 |
response = requests.get('api/formdefs/', remote_service=wcs_site) |
|
67 |
try: |
|
68 |
result = response.json() |
|
69 |
except ValueError: |
|
70 |
return references |
|
71 |
if isinstance(result, (dict,)): |
|
72 |
if result.get('err'): |
|
73 |
continue |
|
74 |
result = result.get('data') |
|
75 |
for form in result: |
|
76 |
references.append(( |
|
77 |
'%s:%s' % (wcs_key, form['slug']), form['title'])) |
|
78 |
return references |
|
79 | ||
80 | ||
81 |
def get_chrono_events(agenda_reference, **kwargs): |
|
82 |
chrono_key, chrono_slug = agenda_reference.split(':') |
|
83 |
chrono = get_service('chrono', chrono_key) |
|
84 |
response = requests.get('api/agenda/%s/datetimes/' % chrono_slug, remote_service=chrono) |
|
85 |
try: |
|
86 |
result = response.json() |
|
87 |
except ValueError: |
|
88 |
return {} |
|
89 |
return result.get('data', []) |
|
90 | ||
91 | ||
92 |
def get_calendar(agenda_reference, offset): |
|
93 |
calendar = {} |
|
94 |
events = get_chrono_events(agenda_reference) |
|
95 |
for event in events: |
|
96 |
event_datetime = parse_datetime(event['datetime']) |
|
97 |
week_ref = event_datetime.isocalendar()[1] |
|
98 |
if week_ref not in calendar: |
|
99 |
calendar[week_ref] = WeekCalendar(week_ref, offset) |
|
100 |
week_cal = calendar[week_ref] |
|
101 |
# add day to week calendar |
|
102 |
if not week_cal.has_day(event_datetime.date()): |
|
103 |
day = WeekDay(event_datetime.date()) |
|
104 |
week_cal.days.append(day) |
|
105 |
else: |
|
106 |
day = week_cal.get_day(event_datetime.date()) |
|
107 |
# add slots to day |
|
108 |
day.add_slots(DaySlot( |
|
109 |
event_datetime, True if not event.get('disabled', True) else False)) |
|
110 | ||
111 |
return sorted(calendar.values(), key=lambda x: x.week) |
|
112 | ||
113 | ||
114 |
def get_form_url_with_params(cell, data): |
|
115 |
tpl = Template(json.dumps(cell.formdef_url_params)) |
|
116 |
session_vars = { |
|
117 |
"session_var_booking_agenda_slug": cell.agenda_reference.split(':')[1], |
|
118 |
"session_var_booking_start": data['start'].isoformat(), |
|
119 |
"session_var_booking_end": data['end'].isoformat() |
|
120 |
} |
|
121 |
session_vars.update(json.loads(tpl.render(Context(data)))) |
|
122 |
wcs_key, wcs_slug = cell.formdef_reference.split(':') |
|
123 |
wcs = get_service('wcs', key=wcs_key) |
|
124 |
response = requests.get('api/formdefs/', remote_service=wcs) |
|
125 |
for formdef in response.json(): |
|
126 |
if formdef['slug'] == wcs_slug: |
|
127 |
break |
|
128 |
url = '%s/?%s' % (formdef['url'], urllib.urlencode(session_vars)) |
|
129 |
print(url) |
|
130 |
return url |
|
131 | ||
132 | ||
133 |
class DaySlot(object): |
|
134 | ||
135 |
def __init__(self, date_time, available): |
|
136 |
self.date_time = date_time |
|
137 |
self.available = available |
|
138 | ||
139 |
def __repr__(self): |
|
140 |
return '<DaySlot date_time=%s - available=%s>' % (self.date_time.isoformat(), self.available) |
|
141 | ||
142 |
@property |
|
143 |
def label(self): |
|
144 |
return '%s' % self.date_time.isoformat() |
|
145 | ||
146 | ||
147 |
class WeekDay(object): |
|
148 | ||
149 |
def __init__(self, date): |
|
150 |
self.date = date |
|
151 |
self.slots = [] |
|
152 | ||
153 |
def __repr__(self): |
|
154 |
return '<WeekDay %s >' % self.date.isoformat() |
|
155 | ||
156 |
def add_slots(self, slot): |
|
157 |
if slot not in self.slots: |
|
158 |
self.slots.append(slot) |
|
159 | ||
160 |
def get_slot(self, slot_time): |
|
161 |
for slot in self.slots: |
|
162 |
if slot.date_time.time() == slot_time: |
|
163 |
return slot |
|
164 |
slot_datetime = datetime.datetime.combine(self.date, slot_time) |
|
165 |
return DaySlot(slot_datetime, False) |
|
166 | ||
167 |
def get_minimum_slot(self): |
|
168 |
return min(self.slots, key=lambda x: x.date_time) |
|
169 | ||
170 |
def get_maximum_slot(self): |
|
171 |
return max(self.slots, key=lambda x: x.date_time) |
|
172 | ||
173 |
def is_available(self, slot_time): |
|
174 |
return self.get_slot(slot_time).available |
|
175 | ||
176 | ||
177 |
class WeekCalendar(object): |
|
178 | ||
179 |
def __init__(self, week, offset): |
|
180 |
self.week = week |
|
181 |
self.offset = offset |
|
182 |
self.days = [] |
|
183 | ||
184 |
def __repr__(self): |
|
185 |
return '<WeekCalendar - %s>' % self.week |
|
186 | ||
187 |
def get_slots(self): |
|
188 |
start = self.get_minimum_slot().time() |
|
189 |
end = self.get_maximum_slot().time() |
|
190 |
offset = self.offset.hour * 60 + self.offset.minute |
|
191 |
while start <= end: |
|
192 |
yield start |
|
193 |
start = datetime.datetime.combine( |
|
194 |
datetime.date.today(), start) + datetime.timedelta(minutes=offset) |
|
195 |
start = start.time() |
|
196 | ||
197 |
def get_days(self): |
|
198 |
if self.days: |
|
199 |
fday = self.days[0].date |
|
200 |
else: |
|
201 |
fday = datetime.datetime.today() |
|
202 |
# only week days |
|
203 |
for index in range(0, 5): |
|
204 |
day = fday + datetime.timedelta(days=index - fday.weekday()) |
|
205 |
yield day |
|
206 | ||
207 |
def get_day(self, date): |
|
208 |
for day in self.days: |
|
209 |
if day.date == date: |
|
210 |
return day |
|
211 |
return None |
|
212 | ||
213 |
def has_day(self, date): |
|
214 |
return bool(self.get_day(date)) |
|
215 | ||
216 |
def get_availability(self, slot): |
|
217 |
if not self.has_day(slot.date()): |
|
218 |
return DaySlot(slot, False) |
|
219 |
day = self.get_day(slot.date()) |
|
220 |
return day.get_slot(slot.time()) |
|
221 | ||
222 |
def get_minimum_slot(self): |
|
223 |
return min([day.get_minimum_slot().date_time for day in self.days]) |
|
224 | ||
225 |
def get_maximum_slot(self): |
|
226 |
return max([day.get_maximum_slot().date_time for day in self.days]) |
combo/apps/calendar/views.py | ||
---|---|---|
1 |
# combo - content management system |
|
2 |
# Copyright (C) 2017 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.http import HttpResponseRedirect |
|
18 |
from django.views.generic import View |
|
19 |
from django.views.generic.detail import SingleObjectMixin |
|
20 |
from django.contrib import messages |
|
21 | ||
22 |
from .models import BookingCalendar |
|
23 |
from .utils import get_form_url_with_params |
|
24 |
from .forms import BookingForm |
|
25 | ||
26 | ||
27 |
class BookingView(SingleObjectMixin, View): |
|
28 | ||
29 |
http_method_names = ['post'] |
|
30 |
model = BookingCalendar |
|
31 | ||
32 |
def post(self, request, *args, **kwargs): |
|
33 |
cell = self.get_object() |
|
34 |
form = BookingForm(request.POST, cell=cell) |
|
35 |
try: |
|
36 |
form.is_valid() |
|
37 |
except ValueError as exc: |
|
38 |
messages.error(request, exc.message) |
|
39 |
return HttpResponseRedirect(cell.page.get_online_url()) |
|
40 |
data = form.cleaned_data |
|
41 |
url = get_form_url_with_params(cell, data) |
|
42 |
return HttpResponseRedirect(url) |
combo/settings.py | ||
---|---|---|
78 | 78 |
'combo.apps.search', |
79 | 79 |
'combo.apps.usersearch', |
80 | 80 |
'combo.apps.maps', |
81 |
'combo.apps.calendar', |
|
81 | 82 |
'haystack', |
82 | 83 |
'xstatic.pkg.chartnew_js', |
83 | 84 |
'xstatic.pkg.leaflet', |
tests/settings.py | ||
---|---|---|
13 | 13 |
'default': {'title': 'test', 'url': 'http://example.org', |
14 | 14 |
'secret': 'combo', 'orig': 'combo', |
15 | 15 |
'backoffice-menu-url': 'http://example.org/manage/',} |
16 |
}, |
|
17 |
'chrono': { |
|
18 |
'default': {'title': 'test', 'url': 'http://chrono.example.org', |
|
19 |
'secret': 'combo', 'orig': 'combo', |
|
20 |
'backoffice-menu-url': 'http://chrono.example.org/manage/',} |
|
16 | 21 |
} |
17 | 22 |
} |
18 | 23 |
tests/test_calendar.py | ||
---|---|---|
1 |
import json |
|
2 |
import urlparse |
|
3 |
import datetime |
|
4 | ||
5 |
import pytest |
|
6 |
import mock |
|
7 | ||
8 |
from django.utils.dateparse import parse_time |
|
9 | ||
10 |
from combo.data.models import Page |
|
11 |
from combo.apps.calendar.models import BookingCalendar |
|
12 |
from combo.apps.calendar.utils import get_calendar |
|
13 |
pytestmark = pytest.mark.django_db |
|
14 | ||
15 |
CHRONO_EVENTS = { |
|
16 |
"data": [ |
|
17 |
{ |
|
18 |
"disabled": False, |
|
19 |
"text": "13 juin 2017 08:00", |
|
20 |
"api": { |
|
21 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/86/" |
|
22 |
}, |
|
23 |
"id": 86, |
|
24 |
"datetime": "2017-06-13 08:00:00" |
|
25 |
}, |
|
26 |
{ |
|
27 |
"disabled": False, |
|
28 |
"text": "13 juin 2017 08:30", |
|
29 |
"api": { |
|
30 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/87/" |
|
31 |
}, |
|
32 |
"id": 87, |
|
33 |
"datetime": "2017-06-13 08:30:00" |
|
34 |
}, |
|
35 |
{ |
|
36 |
"disabled": False, |
|
37 |
"text": "13 juin 2017 09:00", |
|
38 |
"api": { |
|
39 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/88/" |
|
40 |
}, |
|
41 |
"id": 88, |
|
42 |
"datetime": "2017-06-13 09:00:00" |
|
43 |
}, |
|
44 |
{ |
|
45 |
"disabled": False, |
|
46 |
"text": "13 juin 2017 09:30", |
|
47 |
"api": { |
|
48 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
49 |
}, |
|
50 |
"id": 89, |
|
51 |
"datetime": "2017-06-13 09:30:00" |
|
52 |
}, |
|
53 |
{ |
|
54 |
"disabled": False, |
|
55 |
"text": "14 juin 2017 09:30", |
|
56 |
"api": { |
|
57 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
58 |
}, |
|
59 |
"id": 90, |
|
60 |
"datetime": "2017-06-14 09:30:00" |
|
61 |
}, |
|
62 |
{ |
|
63 |
"disabled": False, |
|
64 |
"text": "14 juin 2017 10:00", |
|
65 |
"api": { |
|
66 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
67 |
}, |
|
68 |
"id": 91, |
|
69 |
"datetime": "2017-06-14 10:00:00" |
|
70 |
}, |
|
71 |
{ |
|
72 |
"disabled": True, |
|
73 |
"text": "14 juin 2017 15:00", |
|
74 |
"api": { |
|
75 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
76 |
}, |
|
77 |
"id": 91, |
|
78 |
"datetime": "2017-06-14 15:00:00" |
|
79 |
} |
|
80 | ||
81 |
] |
|
82 |
} |
|
83 | ||
84 | ||
85 |
WCS_FORMDEFS = [ |
|
86 |
{ |
|
87 |
"count": 12, |
|
88 |
"category": "common", |
|
89 |
"functions": { |
|
90 |
"_receiver": { |
|
91 |
"label": "Recipient" |
|
92 |
}, |
|
93 |
}, |
|
94 |
"authentication_required": False, |
|
95 |
"description": "", |
|
96 |
"title": "Demande de place en creche", |
|
97 |
"url": "http://example.net/demande-de-place-en-creche/", |
|
98 |
"category_slug": "common", |
|
99 |
"redirection": False, |
|
100 |
"keywords": [], |
|
101 |
"slug": "demande-de-place-en-creche" |
|
102 |
} |
|
103 |
] |
|
104 | ||
105 | ||
106 |
def str2datetime(sdt): |
|
107 |
return datetime.datetime.strptime(sdt, '%Y-%m-%dT%H:%M:%S') |
|
108 | ||
109 | ||
110 |
class MockedRequestResponse(mock.Mock): |
|
111 | ||
112 |
def json(self): |
|
113 |
return json.loads(self.content) |
|
114 | ||
115 | ||
116 |
def mocked_requests_get(*args, **kwargs): |
|
117 |
remote_service = kwargs.get('remote_service') |
|
118 |
if 'chrono' in remote_service['url']: |
|
119 |
return MockedRequestResponse( |
|
120 |
content=json.dumps(CHRONO_EVENTS)) |
|
121 |
else: |
|
122 |
return MockedRequestResponse( |
|
123 |
content=json.dumps(WCS_FORMDEFS)) |
|
124 | ||
125 | ||
126 |
@pytest.fixture |
|
127 |
def cell(db): |
|
128 |
page = Page.objects.create(title='wjatever', slug='test', template_name='standard') |
|
129 |
cell = BookingCalendar( |
|
130 |
page=page, title='Example Of Calendar', |
|
131 |
agenda_reference='default:test', |
|
132 |
formdef_reference='default:test', |
|
133 |
formdef_url_params={"session_var_whatever": "whatever"}, |
|
134 |
slot_duration=parse_time('00:30'), |
|
135 |
minimal_event_duration=parse_time('01:00'), |
|
136 |
placeholder='content', order=0 |
|
137 |
) |
|
138 |
cell.save() |
|
139 |
return cell |
|
140 | ||
141 | ||
142 |
@mock.patch('combo.apps.calendar.utils.requests.get', side_effect=mocked_requests_get) |
|
143 |
def test_cell_rendering(mocked_get, app, cell): |
|
144 |
page = app.get('/test/') |
|
145 |
# test without selecting slots |
|
146 |
resp = page.form.submit().follow() |
|
147 |
assert 'Please select slots' in resp.content |
|
148 |
# test with slots from different day |
|
149 |
resp.form.set('slots', True, 0) |
|
150 |
resp.form.set('slots', True, 1) |
|
151 |
resp.form.set('slots', True, 4) |
|
152 |
resp = resp.form.submit().follow() |
|
153 |
# test with non contiguous slots |
|
154 |
assert 'Please select slots of the same day' in resp.content |
|
155 |
resp.form.set('slots', True, 0) |
|
156 |
resp.form.set('slots', True, 2) |
|
157 |
resp = resp.form.submit().follow() |
|
158 |
assert 'Please select contiguous slots' in resp.content |
|
159 |
# test with invalid booking duration |
|
160 |
resp.form.set('slots', True, 0) |
|
161 |
resp = resp.form.submit().follow() |
|
162 |
assert 'Minimal booking duration is 01:00' in resp.content |
|
163 |
# test with valid selected slots |
|
164 |
resp.form.set('slots', True, 0) |
|
165 |
resp.form.set('slots', True, 1) |
|
166 |
resp.form.set('slots', True, 2) |
|
167 |
resp = resp.form.submit() |
|
168 |
parsed = urlparse.urlparse(resp.url) |
|
169 |
qs = urlparse.parse_qs(parsed.query) |
|
170 |
assert qs['session_var_booking_agenda_slug'] == ['test'] |
|
171 |
assert qs['session_var_booking_start'] == ['2017-06-13T08:00:00'] |
|
172 |
assert qs['session_var_booking_end'] == ['2017-06-13T09:30:00'] |
|
173 |
assert qs['session_var_whatever'] == ['whatever'] |
|
174 | ||
175 | ||
176 |
@mock.patch('combo.apps.calendar.utils.requests.get', side_effect=mocked_requests_get) |
|
177 |
def test_calendar(mocked_get, cell): |
|
178 |
cal = get_calendar('default:whatever', cell.slot_duration)[0] |
|
179 |
assert len(cal.days) == 2 |
|
180 |
for day in cal.get_days(): |
|
181 |
assert day in [ |
|
182 |
str2datetime('2017-06-12T08:00:00').date() + datetime.timedelta(days=i) |
|
183 |
for i in range(0, 5)] |
|
184 |
min_slot = str2datetime('2017-06-13T08:00:00') |
|
185 |
max_slot = str2datetime('2017-06-14T15:00:00') |
|
186 |
for slot in cal.get_slots(): |
|
187 |
assert (min_slot.time() <= slot <= max_slot.time()) is True |
|
188 |
assert cal.has_day(min_slot.date()) is True |
|
189 |
assert cal.get_availability(str2datetime('2017-06-14T15:00:00')).available is False |
|
190 |
assert cal.get_minimum_slot().time() == min_slot.time() |
|
191 |
assert cal.get_maximum_slot().time() == max_slot.time() |
|
192 |
assert cal.get_day(max_slot.date()).slots[-1].available is False |
|
0 |
- |