Projet

Général

Profil

0002-manager-add-booking-cancellation-44159.patch

Valentin Deniaud, 03 août 2020 12:56

Télécharger (19,4 ko)

Voir les différences:

Subject: [PATCH 2/2] manager: add booking cancellation (#44159)

 .../0055_booking_cancel_callback_url.py       |  18 +++
 chrono/agendas/models.py                      |   7 +-
 chrono/api/views.py                           |   2 +
 chrono/manager/forms.py                       |  14 +++
 chrono/manager/static/css/style.scss          |   4 +
 .../chrono/manager_agenda_day_view.html       |   1 +
 .../manager_confirm_booking_cancellation.html |  33 +++++
 .../chrono/manager_event_detail_fragment.html |   4 +-
 .../manager_meetings_agenda_month_view.html   |   1 +
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  38 ++++++
 chrono/settings.py                            |   1 +
 tests/test_api.py                             |  15 ++-
 tests/test_manager.py                         | 119 ++++++++++++++++++
 14 files changed, 256 insertions(+), 6 deletions(-)
 create mode 100644 chrono/agendas/migrations/0055_booking_cancel_callback_url.py
 create mode 100644 chrono/manager/templates/chrono/manager_confirm_booking_cancellation.html
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
-