From a2d997e4c6c92e41546352349b9fb4273e16682a Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 8 Jul 2020 16:10:53 +0200 Subject: [PATCH 2/2] manager: add booking cancellation (#44159) --- .../0051_booking_cancel_callback_url.py | 18 ++++ chrono/agendas/models.py | 5 + chrono/api/views.py | 2 + chrono/manager/static/css/style.scss | 4 + .../chrono/manager_agenda_day_view.html | 3 + .../manager_confirm_booking_cancellation.html | 26 ++++++ .../chrono/manager_event_detail_fragment.html | 6 +- .../manager_meetings_agenda_month_view.html | 3 + chrono/manager/urls.py | 5 + chrono/manager/views.py | 28 ++++++ chrono/settings.py | 1 + tests/settings.py | 12 +++ tests/test_api.py | 15 ++- tests/test_manager.py | 92 +++++++++++++++++++ 14 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 chrono/agendas/migrations/0051_booking_cancel_callback_url.py create mode 100644 chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html diff --git a/chrono/agendas/migrations/0051_booking_cancel_callback_url.py b/chrono/agendas/migrations/0051_booking_cancel_callback_url.py new file mode 100644 index 0000000..4daae42 --- /dev/null +++ b/chrono/agendas/migrations/0051_booking_cancel_callback_url.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-07-07 15:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0050_event_slug'), + ] + + operations = [ + migrations.AddField( + model_name='booking', name='cancel_callback_url', field=models.URLField(blank=True), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 4c8d582..262e5b2 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -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 AGENDA_KINDS = ( @@ -917,6 +918,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(): @@ -932,6 +934,9 @@ class Booking(models.Model): self.secondary_booking_set.update(cancellation_datetime=timestamp) self.cancellation_datetime = timestamp self.save() + if self.cancel_callback_url: + r = requests.post(self.cancel_callback_url, remote_service='auto', timeout=15) + r.raise_for_status() def accept(self): self.in_waiting_list = False diff --git a/chrono/api/views.py b/chrono/api/views.py index 284ea1d..6488d60 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -644,6 +644,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) @@ -974,6 +975,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, ) diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss index 4b3fa4b..3059f51 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -289,3 +289,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; +} diff --git a/chrono/manager/templates/chrono/manager_agenda_day_view.html b/chrono/manager/templates/chrono/manager_agenda_day_view.html index 492d670..e95eb26 100644 --- a/chrono/manager/templates/chrono/manager_agenda_day_view.html +++ b/chrono/manager/templates/chrono/manager_agenda_day_view.html @@ -75,6 +75,9 @@ >{% if booking.label or booking.user_name %} {{booking.label}}{% if booking.label and booking.user_name %} - {% endif %} {{booking.user_name}} {% else %}{% trans "booked" %}{% endif %} + {% if user_can_manage %} + {% trans "Cancel" %} + {% endif %} {% endfor %} diff --git a/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html b/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html new file mode 100644 index 0000000..b52d496 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html @@ -0,0 +1,26 @@ +{% extends "chrono/manager_home.html" %} +{% load i18n %} + +{% block appbar %} +

{{ view.model.get_verbose_name }}

+{% endblock %} + +{% block content %} +{% if object.backoffice_url and not object.cancel_callback_url %} +
{# FIXME doesn't work in popup #} +{% trans "This booking has no callback url configured, cancellation will not be accounted for in corresponding form." %} +
+{% endif %} + +
+ {% csrf_token %} +

+ {% trans "Are you sure you want to cancel this booking?" %} +

+ +
+ + {% trans 'Abort' %} +
+
+{% endblock %} diff --git a/chrono/manager/templates/chrono/manager_event_detail_fragment.html b/chrono/manager/templates/chrono/manager_event_detail_fragment.html index 0e01908..7c52492 100644 --- a/chrono/manager/templates/chrono/manager_event_detail_fragment.html +++ b/chrono/manager/templates/chrono/manager_event_detail_fragment.html @@ -21,7 +21,11 @@ diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html index 7574a0a..1cc956f 100644 --- a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html +++ b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html @@ -39,6 +39,9 @@ >{% 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 %} + {% if user_can_manage %} + {% trans "Cancel" %} + {% endif %} {% if not single_desk %}{{ slot.desk }}{% endif %} {% endfor %} diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index c5642eb..49f8bd4 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -169,6 +169,11 @@ urlpatterns = [ views.time_period_exception_source_replace, name='chrono-manager-time-period-exception-source-replace', ), + url( + r'^agendas/(?P\d+)/bookings/(?P\d+)/cancel$', + views.booking_cancel, + name='chrono-manager-booking-cancel', + ), url( r'^agendas/events.csv$', views.agenda_import_events_sample_csv, diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 1f5301d..3df505e 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1699,6 +1699,34 @@ class TimePeriodExceptionSourceRefreshView(ManagedDeskSubobjectMixin, DetailView time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() +class BookingCancelView(ManagedAgendaMixin, DeleteView): + template_name = 'chrono/manager_confirm_booking_cancellation.html' + model = Booking + pk_url_kwarg = 'booking_pk' + + def dispatch(self, request, *args, **kwargs): + self.booking = self.get_object() + return super().dispatch(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + self.booking.cancel() + 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( diff --git a/chrono/settings.py b/chrono/settings.py index 37cadf3..f3e9cff 100644 --- a/chrono/settings.py +++ b/chrono/settings.py @@ -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', ], }, diff --git a/tests/settings.py b/tests/settings.py index dcc1e8e..dd6cce9 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -13,3 +13,15 @@ DATABASES = { 'TEST': {'NAME': 'chrono-test-%s' % os.environ.get("BRANCH_NAME", "").replace('/', '-')[:63],}, } } + +KNOWN_SERVICES = { + 'wcs': { + 'default': { + 'title': 'test', + 'url': 'http://foo.bar/', + 'secret': 'chrono', + 'orig': 'chrono', + 'backoffice-menu-url': 'http://foo.bar/backoffice/', + }, + }, +} diff --git a/tests/test_api.py b/tests/test_api.py index b6db0aa..12b5ef2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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( diff --git a/tests/test_manager.py b/tests/test_manager.py index ac91081..2925f1d 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -32,6 +32,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 @@ -3333,3 +3334,94 @@ 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://foo.bar/'}).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 + assert 'no callback url' in resp.text + resp = resp.form.submit() + assert resp.location.endswith(month_view_url) + + resp = resp.follow() + assert not resp.pyquery.find('div.booking') + booking.refresh_from_db() + assert booking.cancellation_datetime + + # 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://foo.bar/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']) + assert booking2.cancellation_datetime + + # test day view + dai_view_url = '/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day) + booking_url3 = bookings_resp.json['data'][2]['api']['fillslot_url'] + booking_json3 = app.post(booking_url3).json + resp = app.get(dai_view_url) + resp = resp.click('Cancel') + resp = resp.form.submit() + assert resp.location.endswith(dai_view_url) + + booking3 = Booking.objects.get(pk=booking_json3['booking_id']) + assert booking3.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 -- 2.20.1