manager: add booking cancellation (#44159)

This commit is contained in:
Valentin Deniaud 2020-07-08 16:10:53 +02:00
parent 10caec88f6
commit 1864d7466d
14 changed files with 256 additions and 6 deletions

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2020-07-29 09:42
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0054_agenda_categories'),
]
operations = [
migrations.AddField(
model_name='booking', name='cancel_callback_url', field=models.URLField(blank=True),
),
]

View File

@ -46,6 +46,7 @@ from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from chrono.interval import Interval, IntervalSet
from chrono.utils.requests_wrapper import requests as requests_wrapper
AGENDA_KINDS = (
@ -950,6 +951,7 @@ class Booking(models.Model):
user_external_id = models.CharField(max_length=250, blank=True)
user_name = models.CharField(max_length=250, blank=True)
backoffice_url = models.URLField(blank=True)
cancel_callback_url = models.URLField(blank=True)
def save(self, *args, **kwargs):
with transaction.atomic():
@ -959,12 +961,15 @@ class Booking(models.Model):
if self.event.full != initial_value:
self.event.save()
def cancel(self):
def cancel(self, trigger_callback=True):
timestamp = now()
with transaction.atomic():
self.secondary_booking_set.update(cancellation_datetime=timestamp)
self.cancellation_datetime = timestamp
self.save()
if self.cancel_callback_url and trigger_callback:
r = requests_wrapper.post(self.cancel_callback_url, remote_service='auto', timeout=15)
r.raise_for_status()
def accept(self):
self.in_waiting_list = False

View File

@ -642,6 +642,7 @@ class SlotSerializer(serializers.Serializer):
user_name = serializers.CharField(max_length=250, allow_blank=True)
user_display_label = serializers.CharField(max_length=250, allow_blank=True)
backoffice_url = serializers.URLField(allow_blank=True)
cancel_callback_url = serializers.URLField(allow_blank=True)
count = serializers.IntegerField(min_value=1)
cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
force_waiting_list = serializers.BooleanField(default=False)
@ -972,6 +973,7 @@ class Fillslots(APIView):
user_external_id=payload.get('user_external_id', ''),
user_name=payload.get('user_name', ''),
backoffice_url=payload.get('backoffice_url', ''),
cancel_callback_url=payload.get('cancel_callback_url', ''),
user_display_label=payload.get('user_display_label', ''),
extra_data=extra_data,
)

View File

@ -30,6 +30,7 @@ from django.utils.translation import ugettext_lazy as _
from chrono.agendas.models import (
Agenda,
Booking,
Event,
MeetingType,
TimePeriod,
@ -459,3 +460,16 @@ class AgendasImportForm(forms.Form):
class AgendaDuplicateForm(forms.Form):
label = forms.CharField(label=_('New label'), max_length=150, required=False)
class BookingCancelForm(forms.ModelForm):
disable_trigger = forms.BooleanField(
label=_('Do not send cancel trigger to form'), initial=False, required=False, widget=forms.HiddenInput
)
def show_trigger_checkbox(self):
self.fields['disable_trigger'].widget = forms.CheckboxInput()
class Meta:
model = Booking
fields = []

View File

@ -295,3 +295,7 @@ ul.objects-list.single-links li a.link-action-icon.refresh {
div.ui-dialog form p span.datetime input {
width: auto;
}
div.booking a.cancel {
float: right;
}

View File

@ -75,6 +75,7 @@
>{% if booking.label or booking.user_name %}
{{booking.label}}{% if booking.label and booking.user_name %} - {% endif %} {{booking.user_name}}
{% else %}{% trans "booked" %}{% endif %}</a>
<a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
</div>
{% endfor %}
</td>

View File

@ -0,0 +1,33 @@
{% extends "chrono/manager_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{{ view.model.get_verbose_name }}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% if object.backoffice_url and not object.cancel_callback_url %}
<div class="warningnotice">
{% filter urlize %}
{% blocktrans trimmed with backoffice_url=object.backoffice_url %}
This booking has no callback url configured, cancellation must be handled from
corresponding form: {{backoffice_url }}.
{% endblocktrans %}
{% endfilter %}
</div>
{% else %}
{% csrf_token %}
<p>
{% trans "Are you sure you want to cancel this booking?" %}
</p>
<input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
{{ form.as_p }}
<div class="buttons">
<button class="delete-button">{% trans "Proceed with cancellation" %}</button>
<a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a>
</div>
{% endif %}
</form>
{% endblock %}

View File

@ -29,7 +29,9 @@
<ul class="objects-list single-links">
{% for booking in booked %}
<li><a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}>{% if booking.user_name %}{{ booking.user_name }}{% else %}{% trans "Unknown" %}{% endif %},
{{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a></li>
{{ booking.creation_datetime|date:"DATETIME_FORMAT" }}</a>
<a rel="popup" class="delete" href="{% url 'chrono-manager-booking-cancel' pk=agenda.id booking_pk=booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
</li>
{% endfor %}
</ul>
</div>

View File

@ -39,6 +39,7 @@
>{% if slot.booking.label or slot.booking.user_name %}
{{slot.booking.label}}{% if slot.booking.label and slot.booking.user_name %} - {% endif %} {{slot.booking.user_name}}
{% else %}{% trans "booked" %}{% endif %}</a>
<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>
{% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
</div>
{% endfor %}

View File

@ -183,6 +183,11 @@ urlpatterns = [
views.time_period_exception_source_replace,
name='chrono-manager-time-period-exception-source-replace',
),
url(
r'^agendas/(?P<pk>\d+)/bookings/(?P<booking_pk>\d+)/cancel$',
views.booking_cancel,
name='chrono-manager-booking-cancel',
),
url(
r'^agendas/events.csv$',
views.agenda_import_events_sample_csv,

View File

@ -18,6 +18,7 @@ import datetime
import itertools
import json
import math
import requests
import uuid
from django.contrib import messages
@ -86,6 +87,7 @@ from .forms import (
AgendaDuplicateForm,
CategoryAddForm,
CategoryEditForm,
BookingCancelForm,
)
from .utils import import_site
@ -1825,6 +1827,42 @@ class TimePeriodExceptionSourceRefreshView(ManagedDeskSubobjectMixin, DetailView
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
class BookingCancelView(ViewableAgendaMixin, UpdateView):
template_name = 'chrono/manager_confirm_booking_cancellation.html'
model = Booking
pk_url_kwarg = 'booking_pk'
form_class = BookingCancelForm
def dispatch(self, request, *args, **kwargs):
self.booking = self.get_object()
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
trigger_callback = not form.cleaned_data['disable_trigger']
try:
self.booking.cancel(trigger_callback)
except requests.RequestException as e:
form.add_error(None, _('There has been an error sending cancellation notification to form.'))
form.add_error(None, _('Check this box if you are sure you want to proceed anyway.'))
form.show_trigger_checkbox()
return self.form_invalid(form)
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
next_url = self.request.POST.get('next')
if next_url:
return next_url
event = self.booking.event
day = event.start_datetime
return reverse(
'chrono-manager-agenda-month-view',
kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month},
)
booking_cancel = BookingCancelView.as_view()
def menu_json(request):
label = _('Agendas')
json_str = json.dumps(

View File

@ -111,6 +111,7 @@ TEMPLATES = [
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.template.context_processors.request',
'django.contrib.messages.context_processors.messages',
],
},

View File

@ -769,11 +769,18 @@ def test_booking_api(app, some_data, user):
# test with additional data
resp = app.post_json(
'/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id),
params={'label': 'foo', 'user_name': 'bar', 'backoffice_url': 'http://example.net/'},
params={
'label': 'foo',
'user_name': 'bar',
'backoffice_url': 'http://example.net/',
'cancel_callback_url': 'http://example.net/jump/trigger/',
},
)
assert Booking.objects.get(id=resp.json['booking_id']).label == 'foo'
assert Booking.objects.get(id=resp.json['booking_id']).user_name == 'bar'
assert Booking.objects.get(id=resp.json['booking_id']).backoffice_url == 'http://example.net/'
booking = Booking.objects.get(id=resp.json['booking_id'])
assert booking.label == 'foo'
assert booking.user_name == 'bar'
assert booking.backoffice_url == 'http://example.net/'
assert booking.cancel_callback_url == 'http://example.net/jump/trigger/'
# blank data are OK
resp = app.post_json(

View File

@ -33,6 +33,7 @@ from chrono.agendas.models import (
VirtualMember,
)
from chrono.manager.forms import TimePeriodExceptionForm
from chrono.utils.signature import check_query
pytestmark = pytest.mark.django_db
@ -3587,3 +3588,121 @@ def test_duplicate_agenda(app, admin_user):
resp.form['label'] = 'hop'
resp = resp.form.submit().follow()
assert 'hop' in resp.text
def test_booking_cancellation_meetings_agenda(app, admin_user, manager_user, api_user):
agenda = Agenda.objects.create(label='Passeports', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Desk A')
meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20)
meetingtype.save()
today = datetime.date(2018, 11, 10) # fixed day
timeperiod_weekday = today.weekday()
timeperiod = TimePeriod(
desk=desk, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
)
timeperiod.save()
# book a slot
app.authorization = ('Basic', ('john.doe', 'password'))
bookings_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
booking_url = bookings_resp.json['data'][0]['api']['fillslot_url']
booking_json = app.post_json(booking_url, params={'backoffice_url': 'http://example.org/'}).json
app.reset()
login(app)
booking = Booking.objects.get(pk=booking_json['booking_id'])
date = booking.event.start_datetime
month_view_url = '/manage/agendas/%s/%d/%d/' % (agenda.id, date.year, date.month)
resp = app.get(month_view_url)
assert len(resp.pyquery.find('div.booking a.cancel')) == 1 # cancel button is shown
resp = resp.click('Cancel')
# no callback url was provided at booking, warn user cancellation is forbidden
assert 'no callback url' in resp.text
assert not 'Proceed with cancellation' in resp.text
booking.delete()
# provide callback url this time
booking_url2 = bookings_resp.json['data'][1]['api']['fillslot_url']
booking_json2 = app.post_json(
booking_url2, params={'cancel_callback_url': 'http://example.org/jump/trigger/'}
).json
resp = app.get(month_view_url)
resp = resp.click('Cancel')
assert not 'no callback url' in resp.text
# a signed request is sent to callback_url
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
mock_response = mock.Mock(status_code=200)
mock_send.return_value = mock_response
resp = resp.form.submit()
url = mock_send.call_args[0][0].url
assert check_query(url.split('?', 1)[-1], 'chrono')
booking2 = Booking.objects.get(pk=booking_json2['booking_id'])
resp = resp.follow()
assert not resp.pyquery.find('div.booking')
assert booking2.cancellation_datetime
# request fails
booking_url3 = bookings_resp.json['data'][2]['api']['fillslot_url']
booking_json3 = app.post_json(
booking_url3, params={'cancel_callback_url': 'http://example.org/jump/trigger/'}
).json
booking3 = Booking.objects.get(pk=booking_json3['booking_id'])
def mocked_requests_connection_error(*args, **kwargs):
raise requests.exceptions.ConnectionError('unreachable')
resp = app.get(month_view_url)
resp = resp.click('Cancel')
assert resp.form['disable_trigger'].attrs['type'] == 'hidden'
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
mock_response = mock.Mock(status_code=200)
mock_send.return_value = mock_response
mock_send.side_effect = mocked_requests_connection_error
resp = resp.form.submit()
assert 'error' in resp.text
booking3.refresh_from_db()
assert not booking3.cancellation_datetime
# there is an option to force cancellation
resp.form['disable_trigger'] = True
resp = resp.form.submit()
booking3.refresh_from_db()
assert booking3.cancellation_datetime
# test day view
day_view_url = '/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day)
booking_url4 = bookings_resp.json['data'][3]['api']['fillslot_url']
booking_json4 = app.post(booking_url4).json
resp = app.get(day_view_url)
resp = resp.click('Cancel')
resp = resp.form.submit()
assert resp.location.endswith(day_view_url)
booking4 = Booking.objects.get(pk=booking_json4['booking_id'])
assert booking4.cancellation_datetime
def test_booking_cancellation_events_agenda(app, admin_user):
agenda = Agenda.objects.create(label='Events', kind='events')
event = Event(label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda)
event.save()
booking = Booking.objects.create(event=event)
login(app)
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
assert 'Bookings (1/10)' in resp.text
resp = resp.click('Cancel')
resp = resp.form.submit()
assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
booking.refresh_from_db()
assert booking.cancellation_datetime
resp = resp.follow()
assert 'Bookings (0/10)' in resp.text