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 |
from django import forms |
|
18 |
from django.utils.translation import ugettext_lazy as _ |
|
19 |
from django.utils.dateparse import parse_datetime, parse_time |
|
20 | ||
21 |
from .models import BookingCalendar |
|
22 |
from .utils import get_agendas |
|
23 |
from combo.apps.wcs.utils import get_wcs_options |
|
24 | ||
25 | ||
26 |
class BookingCalendarForm(forms.ModelForm): |
|
27 | ||
28 |
class Meta: |
|
29 |
model = BookingCalendar |
|
30 |
fields = ( |
|
31 |
'title', 'agenda_reference', 'formdef_reference', |
|
32 |
'slot_duration', 'minimal_booking_duration') |
|
33 | ||
34 |
def __init__(self, *args, **kwargs): |
|
35 |
super(BookingCalendarForm, self).__init__(*args, **kwargs) |
|
36 |
agenda_references = get_agendas() |
|
37 |
formdef_references = get_wcs_options('/api/formdefs/') |
|
38 |
self.fields['agenda_reference'].widget = forms.Select(choices=agenda_references) |
|
39 |
self.fields['formdef_reference'].widget = forms.Select(choices=formdef_references) |
|
40 | ||
41 | ||
42 |
class BookingForm(forms.Form): |
|
43 | ||
44 |
def __init__(self, *args, **kwargs): |
|
45 |
self.cell = kwargs.pop('cell') |
|
46 |
super(BookingForm, self).__init__(*args, **kwargs) |
|
47 |
self.cleaned_data = {} |
|
48 | ||
49 |
def is_valid(self): |
|
50 |
slots = getattr(self.data, 'getlist', lambda x: [])('slots') |
|
51 |
# check that at least one slot if selected |
|
52 |
if not slots: |
|
53 |
raise ValueError(_('Please select slots')) |
|
54 |
offset = self.cell.slot_duration |
|
55 |
start_dt = parse_datetime(slots[0]) |
|
56 |
end_dt = parse_datetime(slots[-1]) + offset |
|
57 |
slots.append(end_dt.isoformat()) |
|
58 | ||
59 |
# check that all slots are part of the same day |
|
60 |
for slot in slots: |
|
61 |
if parse_datetime(slot).date() != start_dt.date(): |
|
62 |
raise ValueError(_('Please select slots of the same day')) |
|
63 | ||
64 |
# check that slots datetime are contiguous |
|
65 |
start = start_dt |
|
66 |
while start <= end_dt: |
|
67 |
if start.isoformat() not in slots: |
|
68 |
raise ValueError(_('Please select contiguous slots')) |
|
69 |
start = start + offset |
|
70 | ||
71 |
# check that event booking duration >= minimal booking duration |
|
72 |
min_duration = self.cell.minimal_booking_duration |
|
73 |
if not (end_dt - start_dt) >= min_duration: |
|
74 |
str_min_duration = parse_time(str(min_duration)).strftime('%H:%M') |
|
75 |
message = _("Minimal booking duration is %s") % str_min_duration |
|
76 |
raise ValueError(message) |
|
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 datetime |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('data', '0027_page_picture'), |
|
12 |
('auth', '0006_require_contenttypes_0002'), |
|
13 |
] |
|
14 | ||
15 |
operations = [ |
|
16 |
migrations.CreateModel( |
|
17 |
name='BookingCalendar', |
|
18 |
fields=[ |
|
19 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
20 |
('placeholder', models.CharField(max_length=20)), |
|
21 |
('order', models.PositiveIntegerField()), |
|
22 |
('slug', models.SlugField(verbose_name='Slug', blank=True)), |
|
23 |
('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)), |
|
24 |
('public', models.BooleanField(default=True, verbose_name='Public')), |
|
25 |
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')), |
|
26 |
('last_update_timestamp', models.DateTimeField(auto_now=True)), |
|
27 |
('title', models.CharField(max_length=128, null=True, verbose_name='Title', blank=True)), |
|
28 |
('agenda_reference', models.CharField(max_length=128, verbose_name='Agenda')), |
|
29 |
('formdef_reference', models.CharField(max_length=128, verbose_name='Form')), |
|
30 |
('slot_duration', models.DurationField(default=datetime.timedelta(0, 1800), help_text='Format is hours:minutes:seconds', verbose_name='Slot duration')), |
|
31 |
('minimal_booking_duration', models.DurationField(default=datetime.timedelta(0, 3600), help_text='Format is hours:minutes:seconds', verbose_name='Minimal booking duration')), |
|
32 |
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)), |
|
33 |
('page', models.ForeignKey(to='data.Page')), |
|
34 |
], |
|
35 |
options={ |
|
36 |
'verbose_name': 'Booking Calendar', |
|
37 |
}, |
|
38 |
), |
|
39 |
] |
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 |
import datetime |
|
18 | ||
19 |
from django.db import models |
|
20 |
from django.utils.translation import ugettext_lazy as _ |
|
21 |
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger |
|
22 | ||
23 |
from combo.data.models import CellBase |
|
24 |
from combo.data.library import register_cell_class |
|
25 |
from .utils import is_chrono_enabled, is_wcs_enabled, get_calendar |
|
26 | ||
27 | ||
28 |
@register_cell_class |
|
29 |
class BookingCalendar(CellBase): |
|
30 | ||
31 |
title = models.CharField(_('Title'), max_length=128, blank=True, null=True) |
|
32 |
agenda_reference = models.CharField(_('Agenda'), max_length=128) |
|
33 |
formdef_reference = models.CharField(_('Form'), max_length=128) |
|
34 |
slot_duration = models.DurationField( |
|
35 |
_('Slot duration'), default=datetime.timedelta(minutes=30), |
|
36 |
help_text=_('Format is hours:minutes:seconds')) |
|
37 |
minimal_booking_duration = models.DurationField( |
|
38 |
_('Minimal booking duration'), default=datetime.timedelta(hours=1), |
|
39 |
help_text=_('Format is hours:minutes:seconds')) |
|
40 | ||
41 |
template_name = 'calendar/booking_calendar_cell.html' |
|
42 | ||
43 |
class Meta: |
|
44 |
verbose_name = _('Booking Calendar') |
|
45 | ||
46 |
def get_default_form_class(self): |
|
47 |
from .forms import BookingCalendarForm |
|
48 |
return BookingCalendarForm |
|
49 | ||
50 |
@classmethod |
|
51 |
def is_enabled(cls): |
|
52 |
return is_chrono_enabled() and is_wcs_enabled() |
|
53 | ||
54 |
def render(self, context): |
|
55 |
request = context['request'] |
|
56 |
page = request.GET.get('week_%s' % self.pk, 1) |
|
57 |
# get calendar |
|
58 |
calendar = get_calendar(self.agenda_reference, self.slot_duration) |
|
59 |
paginator = Paginator(calendar, 1) |
|
60 |
try: |
|
61 |
cal_page = paginator.page(page) |
|
62 |
except PageNotAnInteger: |
|
63 |
cal_page = paginator.page(1) |
|
64 |
except (EmptyPage,): |
|
65 |
cal_page = paginator.page(paginator.num_pages) |
|
66 |
context['calendar'] = cal_page |
|
67 |
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.id}}"> |
|
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 class="previous" href="?week_{{cell.id}}={{ calendar.previous_page_number }}">{% trans "previous week" %}</a> |
|
12 |
{% else %} |
|
13 |
<span class="previous">{% trans "previous week" %}</span> |
|
14 |
{% endif %} |
|
15 |
<span class="current"> |
|
16 |
{{ calendar.number }} / {{ calendar.paginator.num_pages }} |
|
17 |
</span> |
|
18 |
{% if calendar.has_next %} |
|
19 |
<a class="next" href="?week_{{cell.id}}={{ calendar.next_page_number }}">{% trans "next week" %}</a> |
|
20 |
{% else %} |
|
21 |
<span class="next">{% trans "next week" %}</span> |
|
22 |
{% endif %} |
|
23 |
</p> |
|
24 |
{% endif %} |
|
25 | ||
26 |
{% if calendar %} |
|
27 |
<form method="POST" action="{% url 'calendar-booking' cell.id %}"> |
|
28 |
{% csrf_token %} |
|
29 |
<table id="cal-table-{{cell.id}}"> |
|
30 |
{% for cal in calendar %} |
|
31 |
<thead> |
|
32 |
<tr> |
|
33 |
<th></th> |
|
34 |
{% for day in cal.get_days %} |
|
35 |
<th>{{day|date:"SHORT_DATE_FORMAT"}}</th> |
|
36 |
{% endfor %} |
|
37 |
</tr> |
|
38 |
</thead> |
|
39 |
<tbody> |
|
40 |
{% for slot in cal.get_slots %} |
|
41 |
<tr> |
|
42 |
<th>{{slot|date:"TIME_FORMAT"}}</th> |
|
43 |
{% for day in cal.get_days %} |
|
44 |
{% get_day_slot cal day=day slot=slot as value %} |
|
45 |
{% if not value.exist %} |
|
46 |
<td class="absent"></td> |
|
47 |
{% elif value.available %} |
|
48 |
<td class="available"> |
|
49 |
<input type="checkbox" name="slots" value="{{value.label}}" id="slot-{{cell.id}}-{{value.label}}"/> |
|
50 |
<label for="slot-{{cell.id}}-{{value.label}}"></label> |
|
51 |
</td> |
|
52 |
{% else %} |
|
53 |
<td class="unavailable"></td> |
|
54 |
{% endif %} |
|
55 |
{% endfor %} |
|
56 |
</tr> |
|
57 |
{% endfor %} |
|
58 |
</tbody> |
|
59 |
{% endfor %} |
|
60 |
</table> |
|
61 |
<button class="submit-button">{% trans "Book" context "booking" %}</button> |
|
62 |
</form> |
|
63 |
{% endif %} |
|
64 |
</div> |
|
65 |
</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 get_day_slot(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 urllib |
|
18 |
import datetime |
|
19 | ||
20 |
from django.conf import settings |
|
21 |
from django.utils.dateparse import parse_datetime |
|
22 | ||
23 |
from combo.utils import requests |
|
24 | ||
25 | ||
26 |
def get_services(service_name): |
|
27 |
if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get(service_name): |
|
28 |
return settings.KNOWN_SERVICES[service_name] |
|
29 |
return {} |
|
30 | ||
31 | ||
32 |
def get_wcs_services(): |
|
33 |
return get_services('wcs') |
|
34 | ||
35 | ||
36 |
def get_chrono_service(): |
|
37 |
for chrono_key, chrono_site in get_services('chrono').iteritems(): |
|
38 |
if not chrono_site.get('secondary', True): |
|
39 |
chrono_site['slug'] = chrono_key |
|
40 |
return chrono_site |
|
41 |
return {} |
|
42 | ||
43 | ||
44 |
def is_chrono_enabled(): |
|
45 |
return bool(get_chrono_service()) |
|
46 | ||
47 | ||
48 |
def is_wcs_enabled(): |
|
49 |
return bool(get_wcs_services()) |
|
50 | ||
51 | ||
52 |
def get_agendas(): |
|
53 |
chrono = get_chrono_service() |
|
54 |
references = [] |
|
55 |
response = requests.get('api/agenda/', remote_service=chrono, without_user=True) |
|
56 |
try: |
|
57 |
result = response.json() |
|
58 |
except ValueError: |
|
59 |
return references |
|
60 |
for agenda in result.get('data'): |
|
61 |
references.append(( |
|
62 |
'%s:%s' % (chrono['slug'], agenda['slug']), agenda['text'])) |
|
63 |
return references |
|
64 | ||
65 | ||
66 |
def get_chrono_events(agenda_reference): |
|
67 |
chrono_key, chrono_slug = agenda_reference.split(':') |
|
68 |
chrono = get_chrono_service() |
|
69 |
response = requests.get('api/agenda/%s/datetimes/' % chrono_slug, remote_service=chrono, without_user=True) |
|
70 |
try: |
|
71 |
result = response.json() |
|
72 |
except ValueError: |
|
73 |
return [] |
|
74 |
return result.get('data', []) |
|
75 | ||
76 | ||
77 |
def get_calendar(agenda_reference, offset): |
|
78 |
if not agenda_reference: |
|
79 |
return [] |
|
80 |
events = get_chrono_events(agenda_reference) |
|
81 |
calendar = {} |
|
82 |
for event in events: |
|
83 |
event_datetime = parse_datetime(event['datetime']) |
|
84 |
week_ref = event_datetime.isocalendar()[1] |
|
85 |
if week_ref not in calendar: |
|
86 |
calendar[week_ref] = WeekCalendar(week_ref, offset) |
|
87 |
week_cal = calendar[week_ref] |
|
88 |
# add day to week calendar |
|
89 |
if not week_cal.has_day(event_datetime.date()): |
|
90 |
day = WeekDay(event_datetime.date()) |
|
91 |
week_cal.days.append(day) |
|
92 |
else: |
|
93 |
day = week_cal.get_day(event_datetime.date()) |
|
94 |
# add slots to day |
|
95 |
day.add_slots(DaySlot( |
|
96 |
event_datetime, True if not event.get('disabled', True) else False)) |
|
97 | ||
98 |
return sorted(calendar.values(), key=lambda x: x.week) |
|
99 | ||
100 | ||
101 |
def get_form_url_with_params(cell, data): |
|
102 |
session_vars = { |
|
103 |
"session_var_booking_agenda_slug": cell.agenda_reference.split(':')[1], |
|
104 |
"session_var_booking_start": data['start'].isoformat(), |
|
105 |
"session_var_booking_end": data['end'].isoformat() |
|
106 |
} |
|
107 |
wcs_key, wcs_slug = cell.formdef_reference.split(':') |
|
108 |
wcs = get_wcs_services().get(wcs_key) |
|
109 |
url = '%s/%s/?%s' % (wcs['url'], wcs_slug, urllib.urlencode(session_vars)) |
|
110 |
return url |
|
111 | ||
112 | ||
113 |
class DaySlot(object): |
|
114 | ||
115 |
def __init__(self, date_time, available, exist=True): |
|
116 |
self.date_time = date_time |
|
117 |
self.available = available |
|
118 |
self.exist = exist |
|
119 | ||
120 |
def __repr__(self): |
|
121 |
return '<DaySlot date_time=%s - available=%s>' % (self.date_time.isoformat(), self.available) |
|
122 | ||
123 |
@property |
|
124 |
def label(self): |
|
125 |
return '%s' % self.date_time.isoformat() |
|
126 | ||
127 | ||
128 |
class WeekDay(object): |
|
129 | ||
130 |
def __init__(self, date): |
|
131 |
self.date = date |
|
132 |
self.slots = [] |
|
133 | ||
134 |
def __repr__(self): |
|
135 |
return '<WeekDay %s >' % self.date.isoformat() |
|
136 | ||
137 |
def add_slots(self, slot): |
|
138 |
if slot not in self.slots: |
|
139 |
self.slots.append(slot) |
|
140 | ||
141 |
def get_slot(self, slot_time): |
|
142 |
for slot in self.slots: |
|
143 |
if slot.date_time.time() == slot_time: |
|
144 |
return slot |
|
145 |
slot_datetime = datetime.datetime.combine(self.date, slot_time) |
|
146 |
return DaySlot(slot_datetime, False) |
|
147 | ||
148 |
def get_minimum_slot(self): |
|
149 |
return min(self.slots, key=lambda x: x.date_time.time()) |
|
150 | ||
151 |
def get_maximum_slot(self): |
|
152 |
return max(self.slots, key=lambda x: x.date_time.time()) |
|
153 | ||
154 |
def is_available(self, slot_time): |
|
155 |
return self.get_slot(slot_time).available |
|
156 | ||
157 | ||
158 |
class WeekCalendar(object): |
|
159 | ||
160 |
def __init__(self, week, offset): |
|
161 |
self.week = week |
|
162 |
self.offset = offset |
|
163 |
self.days = [] |
|
164 | ||
165 |
def __repr__(self): |
|
166 |
return '<WeekCalendar - %s>' % self.week |
|
167 | ||
168 |
def get_slots(self): |
|
169 |
start = self.get_minimum_slot() |
|
170 |
end = self.get_maximum_slot() |
|
171 |
while start <= end: |
|
172 |
yield start |
|
173 |
start = datetime.datetime.combine( |
|
174 |
datetime.date.today(), start) + self.offset |
|
175 |
start = start.time() |
|
176 | ||
177 |
def get_days(self): |
|
178 |
if self.days: |
|
179 |
base_day = self.days[0].date |
|
180 |
else: |
|
181 |
base_day = datetime.datetime.today() |
|
182 |
# only week days |
|
183 |
for index in range(0, 5): |
|
184 |
day = base_day + datetime.timedelta(days=index - base_day.weekday()) |
|
185 |
yield day |
|
186 | ||
187 |
def get_day(self, date): |
|
188 |
for day in self.days: |
|
189 |
if day.date == date: |
|
190 |
return day |
|
191 |
return None |
|
192 | ||
193 |
def has_day(self, date): |
|
194 |
return bool(self.get_day(date)) |
|
195 | ||
196 |
def get_availability(self, slot): |
|
197 |
if not self.has_day(slot.date()): |
|
198 |
return DaySlot(slot, False, exist=False) |
|
199 |
day = self.get_day(slot.date()) |
|
200 |
return day.get_slot(slot.time()) |
|
201 | ||
202 |
def get_minimum_slot(self): |
|
203 |
return min([day.get_minimum_slot().date_time.time() for day in self.days]) |
|
204 | ||
205 |
def get_maximum_slot(self): |
|
206 |
return max([day.get_maximum_slot().date_time.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': { |
|
19 |
'title': 'test', 'url': 'http://chrono.example.org', |
|
20 |
'secret': 'combo', 'orig': 'combo', |
|
21 |
'backoffice-menu-url': 'http://chrono.example.org/manage/', |
|
22 |
'secondary': False, |
|
23 |
}, |
|
24 |
'other': { |
|
25 |
'title': 'other', 'url': 'http://other.chrono.example.org', |
|
26 |
'secret': 'combo', 'orig': 'combo', |
|
27 |
'backoffice-menu-url': 'http://other.chrono.example.org/manage/', |
|
28 |
'secondary': True, |
|
29 |
} |
|
16 | 30 |
} |
17 | 31 |
} |
18 | 32 |
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 |
from django.contrib.auth.models import User |
|
10 | ||
11 |
from combo.data.models import Page |
|
12 |
from combo.apps.calendar.models import BookingCalendar |
|
13 |
from combo.apps.calendar.utils import get_calendar, get_chrono_service |
|
14 |
pytestmark = pytest.mark.django_db |
|
15 | ||
16 |
CHRONO_EVENTS = { |
|
17 |
"data": [ |
|
18 |
{ |
|
19 |
"disabled": False, |
|
20 |
"text": "13 juin 2017 08:00", |
|
21 |
"api": { |
|
22 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/86/" |
|
23 |
}, |
|
24 |
"id": 86, |
|
25 |
"datetime": "2017-06-13 08:00:00" |
|
26 |
}, |
|
27 |
{ |
|
28 |
"disabled": False, |
|
29 |
"text": "13 juin 2017 08:30", |
|
30 |
"api": { |
|
31 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/87/" |
|
32 |
}, |
|
33 |
"id": 87, |
|
34 |
"datetime": "2017-06-13 08:30:00" |
|
35 |
}, |
|
36 |
{ |
|
37 |
"disabled": False, |
|
38 |
"text": "13 juin 2017 09:00", |
|
39 |
"api": { |
|
40 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/88/" |
|
41 |
}, |
|
42 |
"id": 88, |
|
43 |
"datetime": "2017-06-13 09:00:00" |
|
44 |
}, |
|
45 |
{ |
|
46 |
"disabled": False, |
|
47 |
"text": "13 juin 2017 09:30", |
|
48 |
"api": { |
|
49 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
50 |
}, |
|
51 |
"id": 89, |
|
52 |
"datetime": "2017-06-13 09:30:00" |
|
53 |
}, |
|
54 |
{ |
|
55 |
"disabled": False, |
|
56 |
"text": "14 juin 2017 09:30", |
|
57 |
"api": { |
|
58 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
59 |
}, |
|
60 |
"id": 90, |
|
61 |
"datetime": "2017-06-14 09:30:00" |
|
62 |
}, |
|
63 |
{ |
|
64 |
"disabled": False, |
|
65 |
"text": "14 juin 2017 10:00", |
|
66 |
"api": { |
|
67 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
68 |
}, |
|
69 |
"id": 91, |
|
70 |
"datetime": "2017-06-14 10:00:00" |
|
71 |
}, |
|
72 |
{ |
|
73 |
"disabled": True, |
|
74 |
"text": "14 juin 2017 15:00", |
|
75 |
"api": { |
|
76 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
77 |
}, |
|
78 |
"id": 91, |
|
79 |
"datetime": "2017-06-14 15:00:00" |
|
80 |
}, |
|
81 |
{ |
|
82 |
"disabled": True, |
|
83 |
"text": "15 juin 2017 10:00", |
|
84 |
"api": { |
|
85 |
"fillslot_url": "http://example.net/api/agenda/whatever/fillslot/89/" |
|
86 |
}, |
|
87 |
"id": 92, |
|
88 |
"datetime": "2017-06-15 10:00:00" |
|
89 |
} |
|
90 | ||
91 | ||
92 |
] |
|
93 |
} |
|
94 | ||
95 | ||
96 |
WCS_FORMDEFS = [ |
|
97 |
{ |
|
98 |
"count": 12, |
|
99 |
"category": "common", |
|
100 |
"functions": { |
|
101 |
"_receiver": { |
|
102 |
"label": "Recipient" |
|
103 |
}, |
|
104 |
}, |
|
105 |
"authentication_required": False, |
|
106 |
"description": "", |
|
107 |
"title": "Demande de place en creche", |
|
108 |
"url": "http://example.net/demande-de-place-en-creche/", |
|
109 |
"category_slug": "common", |
|
110 |
"redirection": False, |
|
111 |
"keywords": [], |
|
112 |
"slug": "demande-de-place-en-creche" |
|
113 |
} |
|
114 |
] |
|
115 | ||
116 | ||
117 |
def login(app, username='admin', password='admin'): |
|
118 |
login_page = app.get('/login/') |
|
119 |
login_form = login_page.forms[0] |
|
120 |
login_form['username'] = username |
|
121 |
login_form['password'] = password |
|
122 |
resp = login_form.submit() |
|
123 |
assert resp.status_int == 302 |
|
124 |
return app |
|
125 | ||
126 | ||
127 |
def str2datetime(sdt): |
|
128 |
return datetime.datetime.strptime(sdt, '%Y-%m-%dT%H:%M:%S') |
|
129 | ||
130 | ||
131 |
class MockedRequestResponse(mock.Mock): |
|
132 | ||
133 |
def json(self): |
|
134 |
return json.loads(self.content) |
|
135 | ||
136 | ||
137 |
def mocked_requests_get(*args, **kwargs): |
|
138 |
remote_service = kwargs.get('remote_service') |
|
139 |
if 'chrono' in remote_service['url']: |
|
140 |
return MockedRequestResponse( |
|
141 |
content=json.dumps(CHRONO_EVENTS)) |
|
142 |
else: |
|
143 |
return MockedRequestResponse( |
|
144 |
content=json.dumps(WCS_FORMDEFS)) |
|
145 | ||
146 | ||
147 |
@pytest.fixture |
|
148 |
def admin(db): |
|
149 |
return User.objects.create_superuser(username='admin', password='admin', email=None) |
|
150 | ||
151 | ||
152 |
@pytest.fixture |
|
153 |
def anonymous(app): |
|
154 |
return app |
|
155 | ||
156 | ||
157 |
@pytest.fixture |
|
158 |
def connected(app, admin): |
|
159 |
return login(app) |
|
160 | ||
161 | ||
162 |
@pytest.fixture(params=['anonymous', 'connected']) |
|
163 |
def client(request, anonymous, connected): |
|
164 |
return locals().get(request.param) |
|
165 | ||
166 | ||
167 |
@pytest.fixture |
|
168 |
def cell(db): |
|
169 |
page = Page.objects.create(title='wjatever', slug='test', template_name='standard') |
|
170 |
cell = BookingCalendar( |
|
171 |
page=page, title='Example Of Calendar', |
|
172 |
agenda_reference='default:test', |
|
173 |
formdef_reference='default:test', |
|
174 |
slot_duration=datetime.timedelta(minutes=30), |
|
175 |
minimal_booking_duration=datetime.timedelta(hours=1), |
|
176 |
placeholder='content', order=0 |
|
177 |
) |
|
178 |
cell.save() |
|
179 |
return cell |
|
180 | ||
181 | ||
182 |
def test_get_chrono_service(settings): |
|
183 |
service = get_chrono_service() |
|
184 |
assert service['title'] == 'test' |
|
185 |
assert service['url'] == 'http://chrono.example.org' |
|
186 |
assert service['secondary'] is False |
|
187 | ||
188 | ||
189 |
@mock.patch('combo.apps.calendar.utils.requests.get', side_effect=mocked_requests_get) |
|
190 |
def test_cell_rendering(mocked_get, client, cell): |
|
191 |
page = client.get('/test/') |
|
192 |
# test without selecting slots |
|
193 |
resp = page.form.submit().follow() |
|
194 |
assert 'Please select slots' in resp.content |
|
195 |
# test with slots from different day |
|
196 |
resp.form.set('slots', True, 0) |
|
197 |
resp.form.set('slots', True, 1) |
|
198 |
resp.form.set('slots', True, 4) |
|
199 |
resp = resp.form.submit().follow() |
|
200 |
# test with non contiguous slots |
|
201 |
assert 'Please select slots of the same day' in resp.content |
|
202 |
resp.form.set('slots', True, 0) |
|
203 |
resp.form.set('slots', True, 2) |
|
204 |
resp = resp.form.submit().follow() |
|
205 |
assert 'Please select contiguous slots' in resp.content |
|
206 |
# test with invalid booking duration |
|
207 |
resp.form.set('slots', True, 0) |
|
208 |
resp = resp.form.submit().follow() |
|
209 |
assert 'Minimal booking duration is 01:00' in resp.content |
|
210 |
# test with valid selected slots |
|
211 |
resp.form.set('slots', True, 0) |
|
212 |
resp.form.set('slots', True, 1) |
|
213 |
resp.form.set('slots', True, 2) |
|
214 |
resp = resp.form.submit() |
|
215 |
parsed = urlparse.urlparse(resp.url) |
|
216 |
qs = urlparse.parse_qs(parsed.query) |
|
217 |
assert qs['session_var_booking_agenda_slug'] == ['test'] |
|
218 |
assert qs['session_var_booking_start'] == ['2017-06-13T08:00:00'] |
|
219 |
assert qs['session_var_booking_end'] == ['2017-06-13T09:30:00'] |
|
220 | ||
221 | ||
222 |
@mock.patch('combo.apps.calendar.utils.requests.get', side_effect=mocked_requests_get) |
|
223 |
def test_calendar(mocked_get, cell): |
|
224 |
cal = get_calendar('default:whatever', cell.slot_duration)[0] |
|
225 |
assert len(cal.days) == 3 |
|
226 |
for day in cal.get_days(): |
|
227 |
assert day in [ |
|
228 |
str2datetime('2017-06-12T08:00:00').date() + datetime.timedelta(days=i) |
|
229 |
for i in range(0, 5)] |
|
230 |
min_slot = str2datetime('2017-06-13T08:00:00') |
|
231 |
max_slot = str2datetime('2017-06-14T15:00:00') |
|
232 |
for slot in cal.get_slots(): |
|
233 |
assert (min_slot.time() <= slot <= max_slot.time()) is True |
|
234 |
assert cal.has_day(min_slot.date()) is True |
|
235 |
assert cal.get_availability(str2datetime('2017-06-14T15:00:00')).available is False |
|
236 |
assert cal.get_minimum_slot() == min_slot.time() |
|
237 |
assert cal.get_maximum_slot() == max_slot.time() |
|
238 |
assert cal.get_day(max_slot.date()).slots[-1].available is False |
|
0 |
- |