manager: add booking cancellation (#44159)
This commit is contained in:
parent
10caec88f6
commit
1864d7466d
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue