0002-manager-add-booking-cancellation-44159.patch
chrono/agendas/migrations/0051_booking_cancel_callback_url.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-07-07 15:56 |
|
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', '0050_event_slug'), |
|
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 = ( |
... | ... | |
917 | 918 |
user_external_id = models.CharField(max_length=250, blank=True) |
918 | 919 |
user_name = models.CharField(max_length=250, blank=True) |
919 | 920 |
backoffice_url = models.URLField(blank=True) |
921 |
cancel_callback_url = models.URLField(blank=True) |
|
920 | 922 | |
921 | 923 |
def save(self, *args, **kwargs): |
922 | 924 |
with transaction.atomic(): |
... | ... | |
926 | 928 |
if self.event.full != initial_value: |
927 | 929 |
self.event.save() |
928 | 930 | |
929 |
def cancel(self): |
|
931 |
def cancel(self, trigger_callback=True):
|
|
930 | 932 |
timestamp = now() |
931 | 933 |
with transaction.atomic(): |
932 | 934 |
self.secondary_booking_set.update(cancellation_datetime=timestamp) |
933 | 935 |
self.cancellation_datetime = timestamp |
934 | 936 |
self.save() |
937 |
if self.cancel_callback_url and trigger_callback: |
|
938 |
r = requests_wrapper.post(self.cancel_callback_url, remote_service='auto', timeout=15) |
|
939 |
r.raise_for_status() |
|
935 | 940 | |
936 | 941 |
def accept(self): |
937 | 942 |
self.in_waiting_list = False |
chrono/api/views.py | ||
---|---|---|
644 | 644 |
user_name = serializers.CharField(max_length=250, allow_blank=True) |
645 | 645 |
user_display_label = serializers.CharField(max_length=250, allow_blank=True) |
646 | 646 |
backoffice_url = serializers.URLField(allow_blank=True) |
647 |
cancel_callback_url = serializers.URLField(allow_blank=True) |
|
647 | 648 |
count = serializers.IntegerField(min_value=1) |
648 | 649 |
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True) |
649 | 650 |
force_waiting_list = serializers.BooleanField(default=False) |
... | ... | |
974 | 975 |
user_external_id=payload.get('user_external_id', ''), |
975 | 976 |
user_name=payload.get('user_name', ''), |
976 | 977 |
backoffice_url=payload.get('backoffice_url', ''), |
978 |
cancel_callback_url=payload.get('cancel_callback_url', ''), |
|
977 | 979 |
user_display_label=payload.get('user_display_label', ''), |
978 | 980 |
extra_data=extra_data, |
979 | 981 |
) |
chrono/manager/forms.py | ||
---|---|---|
29 | 29 | |
30 | 30 |
from chrono.agendas.models import ( |
31 | 31 |
Agenda, |
32 |
Booking, |
|
32 | 33 |
Event, |
33 | 34 |
MeetingType, |
34 | 35 |
TimePeriod, |
... | ... | |
428 | 429 | |
429 | 430 |
class AgendaDuplicateForm(forms.Form): |
430 | 431 |
label = forms.CharField(label=_('New label'), max_length=150, required=False) |
432 | ||
433 | ||
434 |
class BookingCancelForm(forms.ModelForm): |
|
435 |
disable_trigger = forms.BooleanField( |
|
436 |
label=_('Do not send cancel trigger to form'), initial=False, required=False, widget=forms.HiddenInput |
|
437 |
) |
|
438 | ||
439 |
def show_trigger_checkbox(self): |
|
440 |
self.fields['disable_trigger'].widget = forms.CheckboxInput() |
|
441 | ||
442 |
class Meta: |
|
443 |
model = Booking |
|
444 |
fields = [] |
chrono/manager/static/css/style.scss | ||
---|---|---|
289 | 289 |
div.ui-dialog form p span.datetime input { |
290 | 290 |
width: auto; |
291 | 291 |
} |
292 | ||
293 |
div.booking a.cancel { |
|
294 |
float: right; |
|
295 |
} |
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 |
{% if user_can_manage %} |
|
79 |
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a> |
|
80 |
{% endif %} |
|
78 | 81 |
</div> |
79 | 82 |
{% endfor %} |
80 | 83 |
</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 |
{% if object.backoffice_url and not object.cancel_callback_url %} |
|
10 |
<div class="warningnotice">{# FIXME doesn't work in popup #} |
|
11 |
{% trans "This booking has no callback url configured, cancellation will not be accounted for in corresponding form." %} |
|
12 |
</div> |
|
13 |
{% endif %} |
|
14 | ||
15 |
<form method="post"> |
|
16 |
{% csrf_token %} |
|
17 |
<p> |
|
18 |
{% trans "Are you sure you want to cancel this booking?" %} |
|
19 |
</p> |
|
20 |
<input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}"> |
|
21 |
{{ form.as_p }} |
|
22 |
<div class="buttons"> |
|
23 |
<button class="delete-button">{% trans "Proceed with cancellation" %}</button> |
|
24 |
<a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a> |
|
25 |
</div> |
|
26 |
</form> |
|
27 |
{% endblock %} |
chrono/manager/templates/chrono/manager_event_detail_fragment.html | ||
---|---|---|
21 | 21 |
<ul class="objects-list single-links"> |
22 | 22 |
{% for booking in booked %} |
23 | 23 |
<li><a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}>{% if booking.user_name %}{{ booking.user_name }}{% else %}{% trans "Unknown" %}{% endif %}, |
24 |
{{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a></li> |
|
24 |
{{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a> |
|
25 |
{% if user_can_manage %} |
|
26 |
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a> |
|
27 |
{% endif %} |
|
28 |
</li> |
|
25 | 29 |
{% endfor %} |
26 | 30 |
</ul> |
27 | 31 |
</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 |
{% if user_can_manage %} |
|
43 |
<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> |
|
44 |
{% endif %} |
|
42 | 45 |
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %} |
43 | 46 |
</div> |
44 | 47 |
{% endfor %} |
chrono/manager/urls.py | ||
---|---|---|
169 | 169 |
views.time_period_exception_source_replace, |
170 | 170 |
name='chrono-manager-time-period-exception-source-replace', |
171 | 171 |
), |
172 |
url( |
|
173 |
r'^agendas/(?P<pk>\d+)/bookings/(?P<booking_pk>\d+)/cancel$', |
|
174 |
views.booking_cancel, |
|
175 |
name='chrono-manager-booking-cancel', |
|
176 |
), |
|
172 | 177 |
url( |
173 | 178 |
r'^agendas/events.csv$', |
174 | 179 |
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 |
... | ... | |
83 | 84 |
ResourceEditForm, |
84 | 85 |
AgendaResourceForm, |
85 | 86 |
AgendaDuplicateForm, |
87 |
BookingCancelForm, |
|
86 | 88 |
) |
87 | 89 |
from .utils import import_site |
88 | 90 | |
... | ... | |
1699 | 1701 |
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() |
1700 | 1702 | |
1701 | 1703 | |
1704 |
class BookingCancelView(ManagedAgendaMixin, UpdateView): |
|
1705 |
template_name = 'chrono/manager_confirm_booking_cancellation.html' |
|
1706 |
model = Booking |
|
1707 |
pk_url_kwarg = 'booking_pk' |
|
1708 |
form_class = BookingCancelForm |
|
1709 | ||
1710 |
def dispatch(self, request, *args, **kwargs): |
|
1711 |
self.booking = self.get_object() |
|
1712 |
return super().dispatch(request, *args, **kwargs) |
|
1713 | ||
1714 |
def form_valid(self, form): |
|
1715 |
trigger_callback = not form.cleaned_data['disable_trigger'] |
|
1716 |
try: |
|
1717 |
self.booking.cancel(trigger_callback) |
|
1718 |
except requests.RequestException as e: |
|
1719 |
form.add_error(None, _('There has been an error sending cancellation notification to form.')) |
|
1720 |
form.add_error(None, _('Check this box if you are sure you want to proceed anyway.')) |
|
1721 |
form.show_trigger_checkbox() |
|
1722 |
return self.form_invalid(form) |
|
1723 |
return HttpResponseRedirect(self.get_success_url()) |
|
1724 | ||
1725 |
def get_success_url(self): |
|
1726 |
next_url = self.request.POST.get('next') |
|
1727 |
if next_url: |
|
1728 |
return next_url |
|
1729 |
event = self.booking.event |
|
1730 |
day = event.start_datetime |
|
1731 |
return reverse( |
|
1732 |
'chrono-manager-agenda-month-view', |
|
1733 |
kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month}, |
|
1734 |
) |
|
1735 | ||
1736 | ||
1737 |
booking_cancel = BookingCancelView.as_view() |
|
1738 | ||
1739 | ||
1702 | 1740 |
def menu_json(request): |
1703 | 1741 |
label = _('Agendas') |
1704 | 1742 |
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 | ||
---|---|---|
32 | 32 |
VirtualMember, |
33 | 33 |
) |
34 | 34 |
from chrono.manager.forms import TimePeriodExceptionForm |
35 |
from chrono.utils.signature import check_query |
|
35 | 36 | |
36 | 37 |
pytestmark = pytest.mark.django_db |
37 | 38 | |
... | ... | |
3333 | 3334 |
resp.form['label'] = 'hop' |
3334 | 3335 |
resp = resp.form.submit().follow() |
3335 | 3336 |
assert 'hop' in resp.text |
3337 | ||
3338 | ||
3339 |
def test_booking_cancellation_meetings_agenda(app, admin_user, manager_user, api_user): |
|
3340 |
agenda = Agenda.objects.create(label='Passeports', kind='meetings') |
|
3341 |
desk = Desk.objects.create(agenda=agenda, label='Desk A') |
|
3342 |
meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20) |
|
3343 |
meetingtype.save() |
|
3344 |
today = datetime.date(2018, 11, 10) # fixed day |
|
3345 |
timeperiod_weekday = today.weekday() |
|
3346 |
timeperiod = TimePeriod( |
|
3347 |
desk=desk, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0) |
|
3348 |
) |
|
3349 |
timeperiod.save() |
|
3350 | ||
3351 |
# book a slot |
|
3352 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
3353 |
bookings_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug)) |
|
3354 |
booking_url = bookings_resp.json['data'][0]['api']['fillslot_url'] |
|
3355 |
booking_json = app.post_json(booking_url, params={'backoffice_url': 'http://example.org/'}).json |
|
3356 | ||
3357 |
app.reset() |
|
3358 |
login(app) |
|
3359 |
booking = Booking.objects.get(pk=booking_json['booking_id']) |
|
3360 |
date = booking.event.start_datetime |
|
3361 |
month_view_url = '/manage/agendas/%s/%d/%d/' % (agenda.id, date.year, date.month) |
|
3362 |
resp = app.get(month_view_url) |
|
3363 |
assert len(resp.pyquery.find('div.booking a.cancel')) == 1 # cancel button is shown |
|
3364 | ||
3365 |
resp = resp.click('Cancel') |
|
3366 |
# no callback url was provided at booking, warn user |
|
3367 |
assert 'no callback url' in resp.text |
|
3368 |
resp = resp.form.submit() |
|
3369 |
assert resp.location.endswith(month_view_url) |
|
3370 | ||
3371 |
resp = resp.follow() |
|
3372 |
assert not resp.pyquery.find('div.booking') |
|
3373 |
booking.refresh_from_db() |
|
3374 |
assert booking.cancellation_datetime |
|
3375 | ||
3376 |
# provide callback url this time |
|
3377 |
booking_url2 = bookings_resp.json['data'][1]['api']['fillslot_url'] |
|
3378 |
booking_json2 = app.post_json( |
|
3379 |
booking_url2, params={'cancel_callback_url': 'http://example.org/jump/trigger/'} |
|
3380 |
).json |
|
3381 |
resp = app.get(month_view_url) |
|
3382 |
resp = resp.click('Cancel') |
|
3383 |
assert not 'no callback url' in resp.text |
|
3384 | ||
3385 |
# a signed request is sent to callback_url |
|
3386 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
3387 |
mock_response = mock.Mock(status_code=200) |
|
3388 |
mock_send.return_value = mock_response |
|
3389 |
resp = resp.form.submit() |
|
3390 |
url = mock_send.call_args[0][0].url |
|
3391 |
assert check_query(url.split('?', 1)[-1], 'chrono') |
|
3392 | ||
3393 |
booking2 = Booking.objects.get(pk=booking_json2['booking_id']) |
|
3394 |
assert booking2.cancellation_datetime |
|
3395 | ||
3396 |
# request fails |
|
3397 |
booking_url3 = bookings_resp.json['data'][2]['api']['fillslot_url'] |
|
3398 |
booking_json3 = app.post_json( |
|
3399 |
booking_url3, params={'cancel_callback_url': 'http://example.org/jump/trigger/'} |
|
3400 |
).json |
|
3401 |
booking3 = Booking.objects.get(pk=booking_json3['booking_id']) |
|
3402 | ||
3403 |
def mocked_requests_connection_error(*args, **kwargs): |
|
3404 |
raise requests.exceptions.ConnectionError('unreachable') |
|
3405 | ||
3406 |
resp = app.get(month_view_url) |
|
3407 |
resp = resp.click('Cancel') |
|
3408 |
assert resp.form['disable_trigger'].attrs['type'] == 'hidden' |
|
3409 | ||
3410 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
3411 |
mock_response = mock.Mock(status_code=200) |
|
3412 |
mock_send.return_value = mock_response |
|
3413 |
mock_send.side_effect = mocked_requests_connection_error |
|
3414 |
resp = resp.form.submit() |
|
3415 | ||
3416 |
assert 'error' in resp.text |
|
3417 |
booking3.refresh_from_db() |
|
3418 |
assert not booking3.cancellation_datetime |
|
3419 | ||
3420 |
# there is an option to force cancellation |
|
3421 |
resp.form['disable_trigger'] = True |
|
3422 |
resp = resp.form.submit() |
|
3423 |
booking3.refresh_from_db() |
|
3424 |
assert booking3.cancellation_datetime |
|
3425 | ||
3426 |
# test day view |
|
3427 |
day_view_url = '/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day) |
|
3428 |
booking_url4 = bookings_resp.json['data'][3]['api']['fillslot_url'] |
|
3429 |
booking_json4 = app.post(booking_url4).json |
|
3430 |
resp = app.get(day_view_url) |
|
3431 |
resp = resp.click('Cancel') |
|
3432 |
resp = resp.form.submit() |
|
3433 |
assert resp.location.endswith(day_view_url) |
|
3434 | ||
3435 |
booking4 = Booking.objects.get(pk=booking_json4['booking_id']) |
|
3436 |
assert booking4.cancellation_datetime |
|
3437 | ||
3438 | ||
3439 |
def test_booking_cancellation_events_agenda(app, admin_user): |
|
3440 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
3441 |
event = Event(label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda) |
|
3442 |
event.save() |
|
3443 |
booking = Booking.objects.create(event=event) |
|
3444 | ||
3445 |
login(app) |
|
3446 |
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id)) |
|
3447 |
assert 'Bookings (1/10)' in resp.text |
|
3448 | ||
3449 |
resp = resp.click('Cancel') |
|
3450 |
resp = resp.form.submit() |
|
3451 |
assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.id, event.id)) |
|
3452 | ||
3453 |
booking.refresh_from_db() |
|
3454 |
assert booking.cancellation_datetime |
|
3455 | ||
3456 |
resp = resp.follow() |
|
3457 |
assert 'Bookings (0/10)' in resp.text |
|
3336 |
- |