0002-manager-add-booking-cancellation-44159.patch
chrono/agendas/migrations/0055_booking_cancel_callback_url.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-07-29 09:42 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('agendas', '0054_agenda_categories'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='booking', name='cancel_callback_url', field=models.URLField(blank=True), |
|
17 |
), |
|
18 |
] |
chrono/agendas/models.py | ||
---|---|---|
46 | 46 |
from jsonfield import JSONField |
47 | 47 | |
48 | 48 |
from chrono.interval import Interval, IntervalSet |
49 |
from chrono.utils.requests_wrapper import requests as requests_wrapper |
|
49 | 50 | |
50 | 51 | |
51 | 52 |
AGENDA_KINDS = ( |
... | ... | |
950 | 951 |
user_external_id = models.CharField(max_length=250, blank=True) |
951 | 952 |
user_name = models.CharField(max_length=250, blank=True) |
952 | 953 |
backoffice_url = models.URLField(blank=True) |
954 |
cancel_callback_url = models.URLField(blank=True) |
|
953 | 955 | |
954 | 956 |
def save(self, *args, **kwargs): |
955 | 957 |
with transaction.atomic(): |
... | ... | |
959 | 961 |
if self.event.full != initial_value: |
960 | 962 |
self.event.save() |
961 | 963 | |
962 |
def cancel(self): |
|
964 |
def cancel(self, trigger_callback=True):
|
|
963 | 965 |
timestamp = now() |
964 | 966 |
with transaction.atomic(): |
965 | 967 |
self.secondary_booking_set.update(cancellation_datetime=timestamp) |
966 | 968 |
self.cancellation_datetime = timestamp |
967 | 969 |
self.save() |
970 |
if self.cancel_callback_url and trigger_callback: |
|
971 |
r = requests_wrapper.post(self.cancel_callback_url, remote_service='auto', timeout=15) |
|
972 |
r.raise_for_status() |
|
968 | 973 | |
969 | 974 |
def accept(self): |
970 | 975 |
self.in_waiting_list = False |
chrono/api/views.py | ||
---|---|---|
642 | 642 |
user_name = serializers.CharField(max_length=250, allow_blank=True) |
643 | 643 |
user_display_label = serializers.CharField(max_length=250, allow_blank=True) |
644 | 644 |
backoffice_url = serializers.URLField(allow_blank=True) |
645 |
cancel_callback_url = serializers.URLField(allow_blank=True) |
|
645 | 646 |
count = serializers.IntegerField(min_value=1) |
646 | 647 |
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True) |
647 | 648 |
force_waiting_list = serializers.BooleanField(default=False) |
... | ... | |
972 | 973 |
user_external_id=payload.get('user_external_id', ''), |
973 | 974 |
user_name=payload.get('user_name', ''), |
974 | 975 |
backoffice_url=payload.get('backoffice_url', ''), |
976 |
cancel_callback_url=payload.get('cancel_callback_url', ''), |
|
975 | 977 |
user_display_label=payload.get('user_display_label', ''), |
976 | 978 |
extra_data=extra_data, |
977 | 979 |
) |
chrono/manager/forms.py | ||
---|---|---|
30 | 30 | |
31 | 31 |
from chrono.agendas.models import ( |
32 | 32 |
Agenda, |
33 |
Booking, |
|
33 | 34 |
Event, |
34 | 35 |
MeetingType, |
35 | 36 |
TimePeriod, |
... | ... | |
459 | 460 | |
460 | 461 |
class AgendaDuplicateForm(forms.Form): |
461 | 462 |
label = forms.CharField(label=_('New label'), max_length=150, required=False) |
463 | ||
464 | ||
465 |
class BookingCancelForm(forms.ModelForm): |
|
466 |
disable_trigger = forms.BooleanField( |
|
467 |
label=_('Do not send cancel trigger to form'), initial=False, required=False, widget=forms.HiddenInput |
|
468 |
) |
|
469 | ||
470 |
def show_trigger_checkbox(self): |
|
471 |
self.fields['disable_trigger'].widget = forms.CheckboxInput() |
|
472 | ||
473 |
class Meta: |
|
474 |
model = Booking |
|
475 |
fields = [] |
chrono/manager/static/css/style.scss | ||
---|---|---|
295 | 295 |
div.ui-dialog form p span.datetime input { |
296 | 296 |
width: auto; |
297 | 297 |
} |
298 | ||
299 |
div.booking a.cancel { |
|
300 |
float: right; |
|
301 |
} |
chrono/manager/templates/chrono/manager_agenda_day_view.html | ||
---|---|---|
75 | 75 |
>{% if booking.label or booking.user_name %} |
76 | 76 |
{{booking.label}}{% if booking.label and booking.user_name %} - {% endif %} {{booking.user_name}} |
77 | 77 |
{% else %}{% trans "booked" %}{% endif %}</a> |
78 |
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a> |
|
78 | 79 |
</div> |
79 | 80 |
{% endfor %} |
80 | 81 |
</td> |
chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html | ||
---|---|---|
1 |
{% extends "chrono/manager_home.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block appbar %} |
|
5 |
<h2>{{ view.model.get_verbose_name }}</h2> |
|
6 |
{% endblock %} |
|
7 | ||
8 |
{% block content %} |
|
9 |
<form method="post"> |
|
10 |
{% if object.backoffice_url and not object.cancel_callback_url %} |
|
11 |
<div class="warningnotice"> |
|
12 |
{% filter urlize %} |
|
13 |
{% blocktrans trimmed with backoffice_url=object.backoffice_url %} |
|
14 |
This booking has no callback url configured, cancellation must be handled from |
|
15 |
corresponding form: {{backoffice_url }}. |
|
16 |
{% endblocktrans %} |
|
17 |
{% endfilter %} |
|
18 |
</div> |
|
19 |
{% else %} |
|
20 | ||
21 |
{% csrf_token %} |
|
22 |
<p> |
|
23 |
{% trans "Are you sure you want to cancel this booking?" %} |
|
24 |
</p> |
|
25 |
<input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}"> |
|
26 |
{{ form.as_p }} |
|
27 |
<div class="buttons"> |
|
28 |
<button class="delete-button">{% trans "Proceed with cancellation" %}</button> |
|
29 |
<a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a> |
|
30 |
</div> |
|
31 |
{% endif %} |
|
32 |
</form> |
|
33 |
{% endblock %} |
chrono/manager/templates/chrono/manager_event_detail_fragment.html | ||
---|---|---|
29 | 29 |
<ul class="objects-list single-links"> |
30 | 30 |
{% for booking in booked %} |
31 | 31 |
<li><a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}>{% if booking.user_name %}{{ booking.user_name }}{% else %}{% trans "Unknown" %}{% endif %}, |
32 |
{{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a></li> |
|
32 |
{{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a> |
|
33 |
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a> |
|
34 |
</li> |
|
33 | 35 |
{% endfor %} |
34 | 36 |
</ul> |
35 | 37 |
</div> |
chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html | ||
---|---|---|
39 | 39 |
>{% if slot.booking.label or slot.booking.user_name %} |
40 | 40 |
{{slot.booking.label}}{% if slot.booking.label and slot.booking.user_name %} - {% endif %} {{slot.booking.user_name}} |
41 | 41 |
{% else %}{% trans "booked" %}{% endif %}</a> |
42 |
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a> |
|
42 | 43 |
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %} |
43 | 44 |
</div> |
44 | 45 |
{% endfor %} |
chrono/manager/urls.py | ||
---|---|---|
183 | 183 |
views.time_period_exception_source_replace, |
184 | 184 |
name='chrono-manager-time-period-exception-source-replace', |
185 | 185 |
), |
186 |
url( |
|
187 |
r'^agendas/(?P<pk>\d+)/bookings/(?P<booking_pk>\d+)/cancel$', |
|
188 |
views.booking_cancel, |
|
189 |
name='chrono-manager-booking-cancel', |
|
190 |
), |
|
186 | 191 |
url( |
187 | 192 |
r'^agendas/events.csv$', |
188 | 193 |
views.agenda_import_events_sample_csv, |
chrono/manager/views.py | ||
---|---|---|
18 | 18 |
import itertools |
19 | 19 |
import json |
20 | 20 |
import math |
21 |
import requests |
|
21 | 22 |
import uuid |
22 | 23 | |
23 | 24 |
from django.contrib import messages |
... | ... | |
86 | 87 |
AgendaDuplicateForm, |
87 | 88 |
CategoryAddForm, |
88 | 89 |
CategoryEditForm, |
90 |
BookingCancelForm, |
|
89 | 91 |
) |
90 | 92 |
from .utils import import_site |
91 | 93 | |
... | ... | |
1825 | 1827 |
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() |
1826 | 1828 | |
1827 | 1829 | |
1830 |
class BookingCancelView(ViewableAgendaMixin, UpdateView): |
|
1831 |
template_name = 'chrono/manager_confirm_booking_cancellation.html' |
|
1832 |
model = Booking |
|
1833 |
pk_url_kwarg = 'booking_pk' |
|
1834 |
form_class = BookingCancelForm |
|
1835 | ||
1836 |
def dispatch(self, request, *args, **kwargs): |
|
1837 |
self.booking = self.get_object() |
|
1838 |
return super().dispatch(request, *args, **kwargs) |
|
1839 | ||
1840 |
def form_valid(self, form): |
|
1841 |
trigger_callback = not form.cleaned_data['disable_trigger'] |
|
1842 |
try: |
|
1843 |
self.booking.cancel(trigger_callback) |
|
1844 |
except requests.RequestException as e: |
|
1845 |
form.add_error(None, _('There has been an error sending cancellation notification to form.')) |
|
1846 |
form.add_error(None, _('Check this box if you are sure you want to proceed anyway.')) |
|
1847 |
form.show_trigger_checkbox() |
|
1848 |
return self.form_invalid(form) |
|
1849 |
return HttpResponseRedirect(self.get_success_url()) |
|
1850 | ||
1851 |
def get_success_url(self): |
|
1852 |
next_url = self.request.POST.get('next') |
|
1853 |
if next_url: |
|
1854 |
return next_url |
|
1855 |
event = self.booking.event |
|
1856 |
day = event.start_datetime |
|
1857 |
return reverse( |
|
1858 |
'chrono-manager-agenda-month-view', |
|
1859 |
kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month}, |
|
1860 |
) |
|
1861 | ||
1862 | ||
1863 |
booking_cancel = BookingCancelView.as_view() |
|
1864 | ||
1865 | ||
1828 | 1866 |
def menu_json(request): |
1829 | 1867 |
label = _('Agendas') |
1830 | 1868 |
json_str = json.dumps( |
chrono/settings.py | ||
---|---|---|
111 | 111 |
'django.template.context_processors.media', |
112 | 112 |
'django.template.context_processors.static', |
113 | 113 |
'django.template.context_processors.tz', |
114 |
'django.template.context_processors.request', |
|
114 | 115 |
'django.contrib.messages.context_processors.messages', |
115 | 116 |
], |
116 | 117 |
}, |
tests/test_api.py | ||
---|---|---|
769 | 769 |
# test with additional data |
770 | 770 |
resp = app.post_json( |
771 | 771 |
'/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id), |
772 |
params={'label': 'foo', 'user_name': 'bar', 'backoffice_url': 'http://example.net/'}, |
|
772 |
params={ |
|
773 |
'label': 'foo', |
|
774 |
'user_name': 'bar', |
|
775 |
'backoffice_url': 'http://example.net/', |
|
776 |
'cancel_callback_url': 'http://example.net/jump/trigger/', |
|
777 |
}, |
|
773 | 778 |
) |
774 |
assert Booking.objects.get(id=resp.json['booking_id']).label == 'foo' |
|
775 |
assert Booking.objects.get(id=resp.json['booking_id']).user_name == 'bar' |
|
776 |
assert Booking.objects.get(id=resp.json['booking_id']).backoffice_url == 'http://example.net/' |
|
779 |
booking = Booking.objects.get(id=resp.json['booking_id']) |
|
780 |
assert booking.label == 'foo' |
|
781 |
assert booking.user_name == 'bar' |
|
782 |
assert booking.backoffice_url == 'http://example.net/' |
|
783 |
assert booking.cancel_callback_url == 'http://example.net/jump/trigger/' |
|
777 | 784 | |
778 | 785 |
# blank data are OK |
779 | 786 |
resp = app.post_json( |
tests/test_manager.py | ||
---|---|---|
33 | 33 |
VirtualMember, |
34 | 34 |
) |
35 | 35 |
from chrono.manager.forms import TimePeriodExceptionForm |
36 |
from chrono.utils.signature import check_query |
|
36 | 37 | |
37 | 38 |
pytestmark = pytest.mark.django_db |
38 | 39 | |
... | ... | |
3587 | 3588 |
resp.form['label'] = 'hop' |
3588 | 3589 |
resp = resp.form.submit().follow() |
3589 | 3590 |
assert 'hop' in resp.text |
3591 | ||
3592 | ||
3593 |
def test_booking_cancellation_meetings_agenda(app, admin_user, manager_user, api_user): |
|
3594 |
agenda = Agenda.objects.create(label='Passeports', kind='meetings') |
|
3595 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
3596 |
meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20) |
|
3597 |
meetingtype.save() |
|
3598 |
today = datetime.date(2018, 11, 10) # fixed day |
|
3599 |
timeperiod_weekday = today.weekday() |
|
3600 |
timeperiod = TimePeriod( |
|
3601 |
desk=desk, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0) |
|
3602 |
) |
|
3603 |
timeperiod.save() |
|
3604 | ||
3605 |
# book a slot |
|
3606 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
3607 |
bookings_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug)) |
|
3608 |
booking_url = bookings_resp.json['data'][0]['api']['fillslot_url'] |
|
3609 |
booking_json = app.post_json(booking_url, params={'backoffice_url': 'http://example.org/'}).json |
|
3610 | ||
3611 |
app.reset() |
|
3612 |
login(app) |
|
3613 |
booking = Booking.objects.get(pk=booking_json['booking_id']) |
|
3614 |
date = booking.event.start_datetime |
|
3615 |
month_view_url = '/manage/agendas/%s/%d/%d/' % (agenda.id, date.year, date.month) |
|
3616 |
resp = app.get(month_view_url) |
|
3617 |
assert len(resp.pyquery.find('div.booking a.cancel')) == 1 # cancel button is shown |
|
3618 | ||
3619 |
resp = resp.click('Cancel') |
|
3620 |
# no callback url was provided at booking, warn user cancellation is forbidden |
|
3621 |
assert 'no callback url' in resp.text |
|
3622 |
assert not 'Proceed with cancellation' in resp.text |
|
3623 |
booking.delete() |
|
3624 | ||
3625 |
# provide callback url this time |
|
3626 |
booking_url2 = bookings_resp.json['data'][1]['api']['fillslot_url'] |
|
3627 |
booking_json2 = app.post_json( |
|
3628 |
booking_url2, params={'cancel_callback_url': 'http://example.org/jump/trigger/'} |
|
3629 |
).json |
|
3630 |
resp = app.get(month_view_url) |
|
3631 |
resp = resp.click('Cancel') |
|
3632 |
assert not 'no callback url' in resp.text |
|
3633 | ||
3634 |
# a signed request is sent to callback_url |
|
3635 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
3636 |
mock_response = mock.Mock(status_code=200) |
|
3637 |
mock_send.return_value = mock_response |
|
3638 |
resp = resp.form.submit() |
|
3639 |
url = mock_send.call_args[0][0].url |
|
3640 |
assert check_query(url.split('?', 1)[-1], 'chrono') |
|
3641 | ||
3642 |
booking2 = Booking.objects.get(pk=booking_json2['booking_id']) |
|
3643 |
resp = resp.follow() |
|
3644 |
assert not resp.pyquery.find('div.booking') |
|
3645 |
assert booking2.cancellation_datetime |
|
3646 | ||
3647 |
# request fails |
|
3648 |
booking_url3 = bookings_resp.json['data'][2]['api']['fillslot_url'] |
|
3649 |
booking_json3 = app.post_json( |
|
3650 |
booking_url3, params={'cancel_callback_url': 'http://example.org/jump/trigger/'} |
|
3651 |
).json |
|
3652 |
booking3 = Booking.objects.get(pk=booking_json3['booking_id']) |
|
3653 | ||
3654 |
def mocked_requests_connection_error(*args, **kwargs): |
|
3655 |
raise requests.exceptions.ConnectionError('unreachable') |
|
3656 | ||
3657 |
resp = app.get(month_view_url) |
|
3658 |
resp = resp.click('Cancel') |
|
3659 |
assert resp.form['disable_trigger'].attrs['type'] == 'hidden' |
|
3660 | ||
3661 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
3662 |
mock_response = mock.Mock(status_code=200) |
|
3663 |
mock_send.return_value = mock_response |
|
3664 |
mock_send.side_effect = mocked_requests_connection_error |
|
3665 |
resp = resp.form.submit() |
|
3666 | ||
3667 |
assert 'error' in resp.text |
|
3668 |
booking3.refresh_from_db() |
|
3669 |
assert not booking3.cancellation_datetime |
|
3670 | ||
3671 |
# there is an option to force cancellation |
|
3672 |
resp.form['disable_trigger'] = True |
|
3673 |
resp = resp.form.submit() |
|
3674 |
booking3.refresh_from_db() |
|
3675 |
assert booking3.cancellation_datetime |
|
3676 | ||
3677 |
# test day view |
|
3678 |
day_view_url = '/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day) |
|
3679 |
booking_url4 = bookings_resp.json['data'][3]['api']['fillslot_url'] |
|
3680 |
booking_json4 = app.post(booking_url4).json |
|
3681 |
resp = app.get(day_view_url) |
|
3682 |
resp = resp.click('Cancel') |
|
3683 |
resp = resp.form.submit() |
|
3684 |
assert resp.location.endswith(day_view_url) |
|
3685 | ||
3686 |
booking4 = Booking.objects.get(pk=booking_json4['booking_id']) |
|
3687 |
assert booking4.cancellation_datetime |
|
3688 | ||
3689 | ||
3690 |
def test_booking_cancellation_events_agenda(app, admin_user): |
|
3691 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
3692 |
event = Event(label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda) |
|
3693 |
event.save() |
|
3694 |
booking = Booking.objects.create(event=event) |
|
3695 | ||
3696 |
login(app) |
|
3697 |
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id)) |
|
3698 |
assert 'Bookings (1/10)' in resp.text |
|
3699 | ||
3700 |
resp = resp.click('Cancel') |
|
3701 |
resp = resp.form.submit() |
|
3702 |
assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.id, event.id)) |
|
3703 | ||
3704 |
booking.refresh_from_db() |
|
3705 |
assert booking.cancellation_datetime |
|
3706 | ||
3707 |
resp = resp.follow() |
|
3708 |
assert 'Bookings (0/10)' in resp.text |
|
3590 |
- |