Projet

Général

Profil

0001-manager-add-event-cancellation-44157.patch

Valentin Deniaud, 04 août 2020 16:40

Télécharger (16,9 ko)

Voir les différences:

Subject: [PATCH] manager: add event cancellation (#44157)

 .../migrations/0056_event_cancelled.py        | 22 ++++++
 chrono/agendas/models.py                      | 11 +++
 chrono/api/views.py                           |  4 +
 chrono/manager/forms.py                       | 20 ++++-
 chrono/manager/static/css/style.scss          |  4 +
 .../chrono/manager_agenda_event_fragment.html | 15 +++-
 .../manager_confirm_event_cancellation.html   | 35 +++++++++
 chrono/manager/urls.py                        |  5 ++
 chrono/manager/views.py                       | 55 ++++++++++----
 tests/test_manager.py                         | 76 +++++++++++++++++++
 10 files changed, 229 insertions(+), 18 deletions(-)
 create mode 100644 chrono/agendas/migrations/0056_event_cancelled.py
 create mode 100644 chrono/manager/templates/chrono/manager_confirm_event_cancellation.html
chrono/agendas/migrations/0056_event_cancelled.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-07-29 09:47
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', '0055_booking_cancel_callback_url'),
12
    ]
13

  
14
    operations = [
15
        migrations.AddField(
16
            model_name='event',
17
            name='cancelled',
18
            field=models.BooleanField(
19
                default=False, help_text="Cancel this event so that it won't be bookable anymore."
20
            ),
21
        ),
22
    ]
chrono/agendas/models.py
434 434
        assert self.kind == 'events'
435 435

  
436 436
        entries = self.event_set.all()
437
        entries = self.event_set.filter(cancelled=False)
437 438
        # we never want to allow booking for past events.
438 439
        entries = entries.filter(start_datetime__gte=localtime(now()))
439 440
        # exclude non published events
......
773 774
    pricing = models.CharField(_('Pricing'), max_length=150, null=True, blank=True)
774 775
    url = models.CharField(_('URL'), max_length=200, null=True, blank=True)
775 776
    full = models.BooleanField(default=False)
777
    cancelled = models.BooleanField(
778
        default=False, help_text=_("Cancel this event so that it won't be bookable anymore.")
779
    )
776 780
    meeting_type = models.ForeignKey(MeetingType, null=True, on_delete=models.CASCADE)
777 781
    desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE)
778 782
    resources = models.ManyToManyField('Resource')
......
932 936

  
933 937
        return new_event
934 938

  
939
    def cancel(self, trigger_callback=True):
940
        with transaction.atomic():
941
            for booking in self.booking_set.filter(cancellation_datetime__isnull=True).all():
942
                booking.cancel(trigger_callback)
943
            self.cancelled = True
944
            self.save()
945

  
935 946

  
936 947
class Booking(models.Model):
937 948
    event = models.ForeignKey(Event, on_delete=models.CASCADE)
chrono/api/views.py
925 925
                    return Response(
926 926
                        {'err': 1, 'err_class': 'event not bookable', 'err_desc': _('event not bookable')}
927 927
                    )
928
                if event.cancelled:
929
                    return Response(
930
                        {'err': 1, 'err_class': 'event is cancelled', 'err_desc': _('event is cancelled')}
931
                    )
928 932

  
929 933
            if not events.count():
930 934
                return Response(
chrono/manager/forms.py
115 115
            'start_datetime': DateTimeWidget(),
116 116
            'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
117 117
        }
118
        exclude = ['full', 'meeting_type', 'desk', 'slug', 'resources']
118
        exclude = ['full', 'meeting_type', 'desk', 'slug', 'resources', 'cancelled']
119 119

  
120 120

  
121 121
class EventForm(forms.ModelForm):
......
126 126
            'start_datetime': DateTimeWidget(),
127 127
            'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
128 128
        }
129
        exclude = ['full', 'meeting_type', 'desk', 'resources']
129
        exclude = ['full', 'meeting_type', 'desk', 'resources', 'cancelled']
130 130

  
131 131

  
132 132
class AgendaResourceForm(forms.Form):
......
473 473
    class Meta:
474 474
        model = Booking
475 475
        fields = []
476

  
477

  
478
class EventCancelForm(forms.ModelForm):
479
    disable_trigger = forms.BooleanField(
480
        label=_('Do not send cancel triggers to forms'),
481
        initial=False,
482
        required=False,
483
        widget=forms.HiddenInput,
484
    )
485

  
486
    def show_trigger_checkbox(self):
487
        self.fields['disable_trigger'].widget = forms.CheckboxInput()
488

  
489
    class Meta:
490
        model = Event
491
        fields = []
chrono/manager/static/css/style.scss
25 25
	background: #f8f8fe;
26 26
}
27 27

  
28
li.cancelled span.event-info {
29
	text-decoration: line-through;
30
}
31

  
28 32
li span.duration {
29 33
	font-size: 80%;
30 34
}
chrono/manager/templates/chrono/manager_agenda_event_fragment.html
1 1
{% load i18n %}
2 2
<li class="{% if event.booked_places_count > event.places %}overbooking{% endif %}
3 3
           {% if event.main_list_full %}full{% endif %}
4
             {% if event.cancelled %}cancelled{% endif %}
4 5
           {% if not event.in_bookable_period %}not-{% endif %}bookable"
5 6
    {% if event.places %}
6 7
      data-total="{{ event.places }}" data-booked="{{ event.booked_places_count }}"
......
8 9
      data-total="{{ event.waiting_list_places }}" data-booked="{{ event.waiting_list_count }}"
9 10
    {% endif %}
10 11
    ><a href="{% if settings_view %}{% url 'chrono-manager-event-edit' pk=agenda.pk event_pk=event.pk %}?next=settings{% else %}{% url 'chrono-manager-event-view' pk=agenda.pk event_pk=event.pk %}{% endif %}">
11
    {% if event.main_list_full %}<span class="full tag">{% trans "Full" %}</span>{% endif %}
12
    {% if event.cancelled %}
13
    <span class="cancelled tag">{% trans "Cancelled" %}</span>
14
    {% elif event.main_list_full %}
15
    <span class="full tag">{% trans "Full" %}</span>
16
    {% endif %}
17
    <span class="event-info">
12 18
    {% if settings_view %}
13 19
      {% if event.label %}{{ event.label }} {% endif %}[{% trans "identifier:" %} {{ event.slug }}]
14 20
    {% else %}
......
32 38
    {% if not event.in_bookable_period %}
33 39
    ({% trans "out of bookable period" %})
34 40
    {% endif %}
41
    </span>
35 42
        </a>
36
    {% if settings_view %}<a rel="popup" class="delete" href="{% url 'chrono-manager-event-delete' pk=agenda.pk event_pk=event.pk %}?next=settings">{% trans "remove" %}</a>{% endif %}
43
    {% if settings_view %}
44
    <a rel="popup" class="delete" href="{% url 'chrono-manager-event-delete' pk=agenda.pk event_pk=event.pk %}?next=settings">{% trans "remove" %}</a>
45
    {% elif not event.cancelled %}
46
    <a rel="popup" class="link-action-text cancel" href="{% url 'chrono-manager-event-cancel' pk=agenda.pk event_pk=event.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a>
47
    {% endif %}
37 48
    <span class="occupation-bar"></span>
38 49
</li>
chrono/manager/templates/chrono/manager_confirm_event_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

  
10
<form method="post">
11
  {% if cancellation_forbidden %}
12
  <div class="warningnotice">
13
    {% blocktrans trimmed %}
14
    This event has bookings with no callback url configured. Their cancellation must be
15
    handled individually from the forms attached to them. Only then, cancelling this event
16
    will be allowed.
17
    {% endblocktrans %}
18
  </div>
19
  {% else %}
20
  {% csrf_token %}
21
  <p>
22
  {% trans "Are you sure you want to cancel this event?" %}
23
  {% if bookings_count %}
24
  {% blocktrans %}The {{ bookings_count }} related bookings will also be cancelled.{% endblocktrans %}
25
  {% endif %}
26
  </p>
27
  <input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
28
  {{ form.as_p }}
29
  <div class="buttons">
30
    <button class="delete-button">{% trans "Proceed with cancellation" %}</button>
31
    <a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a>
32
  </div>
33
  {% endif %}
34
</form>
35
{% endblock %}
chrono/manager/urls.py
88 88
        views.event_delete,
89 89
        name='chrono-manager-event-delete',
90 90
    ),
91
    url(
92
        r'^agendas/(?P<pk>\d+)/events/(?P<event_pk>\d+)/cancel$',
93
        views.event_cancel,
94
        name='chrono-manager-event-cancel',
95
    ),
91 96
    url(
92 97
        r'^agendas/(?P<pk>\d+)/add-resource/$',
93 98
        views.agenda_add_resource,
chrono/manager/views.py
88 88
    CategoryAddForm,
89 89
    CategoryEditForm,
90 90
    BookingCancelForm,
91
    EventCancelForm,
91 92
)
92 93
from .utils import import_site
93 94

  
......
1827 1828
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
1828 1829

  
1829 1830

  
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)
1831
class CancelView(ViewableAgendaMixin, UpdateView):
1832
    error_msg = _('There has been an error sending cancellation notification to form.')
1839 1833

  
1840 1834
    def form_valid(self, form):
1841 1835
        trigger_callback = not form.cleaned_data['disable_trigger']
1842 1836
        try:
1843
            self.booking.cancel(trigger_callback)
1837
            self.object.cancel(trigger_callback)
1844 1838
        except requests.RequestException as e:
1845
            form.add_error(None, _('There has been an error sending cancellation notification to form.'))
1839
            form.add_error(None, self.error_msg)
1846 1840
            form.add_error(None, _('Check this box if you are sure you want to proceed anyway.'))
1847 1841
            form.show_trigger_checkbox()
1848 1842
            return self.form_invalid(form)
......
1852 1846
        next_url = self.request.POST.get('next')
1853 1847
        if next_url:
1854 1848
            return next_url
1855
        event = self.booking.event
1856
        day = event.start_datetime
1849
        day = self.event.start_datetime
1857 1850
        return reverse(
1858 1851
            'chrono-manager-agenda-month-view',
1859
            kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month},
1852
            kwargs={'pk': self.agenda.pk, 'year': day.year, 'month': day.month},
1860 1853
        )
1861 1854

  
1862 1855

  
1856
class BookingCancelView(CancelView):
1857
    template_name = 'chrono/manager_confirm_booking_cancellation.html'
1858
    model = Booking
1859
    pk_url_kwarg = 'booking_pk'
1860
    form_class = BookingCancelForm
1861

  
1862
    def dispatch(self, request, *args, **kwargs):
1863
        self.event = self.get_object().event
1864
        return super().dispatch(request, *args, **kwargs)
1865

  
1866

  
1863 1867
booking_cancel = BookingCancelView.as_view()
1864 1868

  
1865 1869

  
1870
class EventCancelView(CancelView):
1871
    template_name = 'chrono/manager_confirm_event_cancellation.html'
1872
    model = Event
1873
    pk_url_kwarg = 'event_pk'
1874
    form_class = EventCancelForm
1875
    error_msg = _('An error occured while sending cancellation notifications to forms.')
1876

  
1877
    def dispatch(self, request, *args, **kwargs):
1878
        self.event = self.get_object()
1879
        return super().dispatch(request, *args, **kwargs)
1880

  
1881
    def get_context_data(self, **kwargs):
1882
        context = super().get_context_data(**kwargs)
1883
        context['bookings_count'] = self.event.booking_set.filter(cancellation_datetime__isnull=True).count()
1884
        context['cancellation_forbidden'] = (
1885
            self.event.booking_set.filter(cancel_callback_url='').exclude(backoffice_url='').exists()
1886
        )
1887
        return context
1888

  
1889

  
1890
event_cancel = EventCancelView.as_view()
1891

  
1892

  
1866 1893
def menu_json(request):
1867 1894
    label = _('Agendas')
1868 1895
    json_str = json.dumps(
tests/test_manager.py
3706 3706

  
3707 3707
    resp = resp.follow()
3708 3708
    assert 'Bookings (0/10)' in resp.text
3709

  
3710

  
3711
def test_event_cancellation(app, admin_user):
3712
    agenda = Agenda.objects.create(label='Events', kind='events')
3713
    event = Event.objects.create(
3714
        label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda
3715
    )
3716
    day = event.start_datetime
3717

  
3718
    login(app)
3719
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3720
    assert '0/10 bookings' in resp.text
3721

  
3722
    resp = resp.click('Cancel')
3723
    assert not 'related bookings' in resp.text
3724

  
3725
    booking = Booking.objects.create(event=event)
3726
    booking2 = Booking.objects.create(event=event)
3727

  
3728
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3729
    assert '2/10 bookings' in resp.text
3730

  
3731
    resp = resp.click('Cancel')
3732
    assert '2 related bookings will also be cancelled.' in resp.text
3733

  
3734
    resp = resp.form.submit().follow()
3735
    assert 'Cancelled' in resp.text
3736
    assert '0/10 bookings' in resp.text
3737
    assert Booking.objects.filter(event=event, cancellation_datetime__isnull=False).count() == 2
3738

  
3739

  
3740
def test_event_cancellation_callback_error(app, admin_user):
3741
    agenda = Agenda.objects.create(label='Events', kind='events')
3742
    event = Event.objects.create(
3743
        label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda
3744
    )
3745
    booking = Booking.objects.create(event=event)
3746
    booking2 = Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
3747
    day = event.start_datetime
3748

  
3749
    def mocked_requests_connection_error(*args, **kwargs):
3750
        raise requests.exceptions.ConnectionError('unreachable')
3751

  
3752
    login(app)
3753
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3754
    resp = resp.click('Cancel')
3755
    assert resp.form['disable_trigger'].attrs['type'] == 'hidden'
3756

  
3757
    with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
3758
        mock_response = mock.Mock(status_code=200)
3759
        mock_send.return_value = mock_response
3760
        mock_send.side_effect = mocked_requests_connection_error
3761
        resp = resp.form.submit()
3762

  
3763
    assert 'error' in resp.text
3764
    assert not Booking.objects.filter(cancellation_datetime__isnull=False).exists()
3765

  
3766
    resp.form['disable_trigger'] = True
3767
    resp = resp.form.submit()
3768
    assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 2
3769

  
3770

  
3771
def test_event_cancellation_forbidden(app, admin_user):
3772
    agenda = Agenda.objects.create(label='Events', kind='events')
3773
    event = Event.objects.create(
3774
        label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda
3775
    )
3776
    booking = Booking.objects.create(event=event)
3777
    booking2 = Booking.objects.create(event=event, backoffice_url='http://example.org/backoffice/xx/')
3778
    day = event.start_datetime
3779

  
3780
    login(app)
3781
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3782
    resp = resp.click('Cancel')
3783
    assert 'event has bookings with no callback url configured' in resp.text
3784
    assert 'Proceed with cancellation' not in resp.text
3709
-