Projet

Général

Profil

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

Valentin Deniaud, 09 juillet 2020 17:38

Télécharger (19,5 ko)

Voir les différences:

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

 .../0051_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       |   3 +
 .../manager_confirm_booking_cancellation.html |  27 ++++
 .../chrono/manager_event_detail_fragment.html |   6 +-
 .../manager_meetings_agenda_month_view.html   |   3 +
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  38 ++++++
 chrono/settings.py                            |   1 +
 tests/test_api.py                             |  15 ++-
 tests/test_manager.py                         | 122 ++++++++++++++++++
 14 files changed, 259 insertions(+), 6 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
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
-