Projet

Général

Profil

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

Valentin Deniaud, 09 juillet 2020 11:30

Télécharger (16,9 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                      |  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
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
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():
......
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:
938
                r = requests.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/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
  <div class="buttons">
22
    <button class="delete-button">{% trans "Proceed with cancellation" %}</button>
23
    <a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a>
24
  </div>
25
</form>
26
{% 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
1699 1699
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
1700 1700

  
1701 1701

  
1702
class BookingCancelView(ManagedAgendaMixin, DeleteView):
1703
    template_name = 'chrono/manager_confirm_booking_cancellation.html'
1704
    model = Booking
1705
    pk_url_kwarg = 'booking_pk'
1706

  
1707
    def dispatch(self, request, *args, **kwargs):
1708
        self.booking = self.get_object()
1709
        return super().dispatch(request, *args, **kwargs)
1710

  
1711
    def delete(self, request, *args, **kwargs):
1712
        self.booking.cancel()
1713
        return HttpResponseRedirect(self.get_success_url())
1714

  
1715
    def get_success_url(self):
1716
        next_url = self.request.POST.get('next')
1717
        if next_url:
1718
            return next_url
1719
        event = self.booking.event
1720
        day = event.start_datetime
1721
        return reverse(
1722
            'chrono-manager-agenda-month-view',
1723
            kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month},
1724
        )
1725

  
1726

  
1727
booking_cancel = BookingCancelView.as_view()
1728

  
1729

  
1702 1730
def menu_json(request):
1703 1731
    label = _('Agendas')
1704 1732
    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/settings.py
13 13
        'TEST': {'NAME': 'chrono-test-%s' % os.environ.get("BRANCH_NAME", "").replace('/', '-')[:63],},
14 14
    }
15 15
}
16

  
17
KNOWN_SERVICES = {
18
    'wcs': {
19
        'default': {
20
            'title': 'test',
21
            'url': 'http://foo.bar/',
22
            'secret': 'chrono',
23
            'orig': 'chrono',
24
            'backoffice-menu-url': 'http://foo.bar/backoffice/',
25
        },
26
    },
27
}
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://foo.bar/'}).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://foo.bar/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
    # test day view
3397
    dai_view_url = '/manage/agendas/%s/%d/%d/%d/' % (agenda.id, date.year, date.month, date.day)
3398
    booking_url3 = bookings_resp.json['data'][2]['api']['fillslot_url']
3399
    booking_json3 = app.post(booking_url3).json
3400
    resp = app.get(dai_view_url)
3401
    resp = resp.click('Cancel')
3402
    resp = resp.form.submit()
3403
    assert resp.location.endswith(dai_view_url)
3404

  
3405
    booking3 = Booking.objects.get(pk=booking_json3['booking_id'])
3406
    assert booking3.cancellation_datetime
3407

  
3408

  
3409
def test_booking_cancellation_events_agenda(app, admin_user):
3410
    agenda = Agenda.objects.create(label='Events', kind='events')
3411
    event = Event(label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda)
3412
    event.save()
3413
    booking = Booking.objects.create(event=event)
3414

  
3415
    login(app)
3416
    resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
3417
    assert 'Bookings (1/10)' in resp.text
3418

  
3419
    resp = resp.click('Cancel')
3420
    resp = resp.form.submit()
3421
    assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
3422

  
3423
    booking.refresh_from_db()
3424
    assert booking.cancellation_datetime
3425

  
3426
    resp = resp.follow()
3427
    assert 'Bookings (0/10)' in resp.text
3336
-