From 73b6dcd70cadba9608eda42b155e2099aaf4ad2a Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 9 Jul 2020 12:46:13 +0200 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 diff --git a/chrono/agendas/migrations/0056_event_cancelled.py b/chrono/agendas/migrations/0056_event_cancelled.py new file mode 100644 index 0000000..5b3c435 --- /dev/null +++ b/chrono/agendas/migrations/0056_event_cancelled.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-07-29 09:47 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0055_booking_cancel_callback_url'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='cancelled', + field=models.BooleanField( + default=False, help_text="Cancel this event so that it won't be bookable anymore." + ), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 841c324..f8367b3 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -434,6 +434,7 @@ class Agenda(models.Model): assert self.kind == 'events' entries = self.event_set.all() + entries = self.event_set.filter(cancelled=False) # we never want to allow booking for past events. entries = entries.filter(start_datetime__gte=localtime(now())) # exclude non published events @@ -773,6 +774,9 @@ class Event(models.Model): pricing = models.CharField(_('Pricing'), max_length=150, null=True, blank=True) url = models.CharField(_('URL'), max_length=200, null=True, blank=True) full = models.BooleanField(default=False) + cancelled = models.BooleanField( + default=False, help_text=_("Cancel this event so that it won't be bookable anymore.") + ) meeting_type = models.ForeignKey(MeetingType, null=True, on_delete=models.CASCADE) desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE) resources = models.ManyToManyField('Resource') @@ -932,6 +936,13 @@ class Event(models.Model): return new_event + def cancel(self, trigger_callback=True): + with transaction.atomic(): + for booking in self.booking_set.filter(cancellation_datetime__isnull=True).all(): + booking.cancel(trigger_callback) + self.cancelled = True + self.save() + class Booking(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) diff --git a/chrono/api/views.py b/chrono/api/views.py index f0a27f0..7ae8ea1 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -925,6 +925,10 @@ class Fillslots(APIView): return Response( {'err': 1, 'err_class': 'event not bookable', 'err_desc': _('event not bookable')} ) + if event.cancelled: + return Response( + {'err': 1, 'err_class': 'event is cancelled', 'err_desc': _('event is cancelled')} + ) if not events.count(): return Response( diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index 6dbd684..fda4e8f 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -115,7 +115,7 @@ class NewEventForm(forms.ModelForm): 'start_datetime': DateTimeWidget(), 'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), } - exclude = ['full', 'meeting_type', 'desk', 'slug', 'resources'] + exclude = ['full', 'meeting_type', 'desk', 'slug', 'resources', 'cancelled'] class EventForm(forms.ModelForm): @@ -126,7 +126,7 @@ class EventForm(forms.ModelForm): 'start_datetime': DateTimeWidget(), 'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), } - exclude = ['full', 'meeting_type', 'desk', 'resources'] + exclude = ['full', 'meeting_type', 'desk', 'resources', 'cancelled'] class AgendaResourceForm(forms.Form): @@ -473,3 +473,19 @@ class BookingCancelForm(forms.ModelForm): class Meta: model = Booking fields = [] + + +class EventCancelForm(forms.ModelForm): + disable_trigger = forms.BooleanField( + label=_('Do not send cancel triggers to forms'), + initial=False, + required=False, + widget=forms.HiddenInput, + ) + + def show_trigger_checkbox(self): + self.fields['disable_trigger'].widget = forms.CheckboxInput() + + class Meta: + model = Event + fields = [] diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss index 75ca838..755c9f0 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -25,6 +25,10 @@ li.full { background: #f8f8fe; } +li.cancelled span.event-info { + text-decoration: line-through; +} + li span.duration { font-size: 80%; } diff --git a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html index 00e0e93..a3e437a 100644 --- a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html +++ b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html @@ -1,6 +1,7 @@ {% load i18n %}
  • - {% if event.main_list_full %}{% trans "Full" %}{% endif %} + {% if event.cancelled %} + {% trans "Cancelled" %} + {% elif event.main_list_full %} + {% trans "Full" %} + {% endif %} + {% if settings_view %} {% if event.label %}{{ event.label }} {% endif %}[{% trans "identifier:" %} {{ event.slug }}] {% else %} @@ -32,7 +38,12 @@ {% if not event.in_bookable_period %} ({% trans "out of bookable period" %}) {% endif %} + - {% if settings_view %}{% trans "remove" %}{% endif %} + {% if settings_view %} + {% trans "remove" %} + {% elif not event.cancelled %} + {% trans "Cancel" %} + {% endif %}
  • diff --git a/chrono/manager/templates/chrono/manager_confirm_event_cancellation.html b/chrono/manager/templates/chrono/manager_confirm_event_cancellation.html new file mode 100644 index 0000000..0212520 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_confirm_event_cancellation.html @@ -0,0 +1,35 @@ +{% extends "chrono/manager_home.html" %} +{% load i18n %} + +{% block appbar %} +

    {{ view.model.get_verbose_name }}

    +{% endblock %} + +{% block content %} + +
    + {% if cancellation_forbidden %} +
    + {% blocktrans trimmed %} + This event has bookings with no callback url configured. Their cancellation must be + handled individually from the forms attached to them. Only then, cancelling this event + will be allowed. + {% endblocktrans %} +
    + {% else %} + {% csrf_token %} +

    + {% trans "Are you sure you want to cancel this event?" %} + {% if bookings_count %} + {% blocktrans %}The {{ bookings_count }} related bookings will also be cancelled.{% endblocktrans %} + {% endif %} +

    + + {{ form.as_p }} +
    + + {% trans 'Abort' %} +
    + {% endif %} +
    +{% endblock %} diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index 9b81f27..c36062f 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -88,6 +88,11 @@ urlpatterns = [ views.event_delete, name='chrono-manager-event-delete', ), + url( + r'^agendas/(?P\d+)/events/(?P\d+)/cancel$', + views.event_cancel, + name='chrono-manager-event-cancel', + ), url( r'^agendas/(?P\d+)/add-resource/$', views.agenda_add_resource, diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 157390b..3b96505 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -88,6 +88,7 @@ from .forms import ( CategoryAddForm, CategoryEditForm, BookingCancelForm, + EventCancelForm, ) from .utils import import_site @@ -1827,22 +1828,15 @@ class TimePeriodExceptionSourceRefreshView(ManagedDeskSubobjectMixin, DetailView time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() -class BookingCancelView(ViewableAgendaMixin, UpdateView): - template_name = 'chrono/manager_confirm_booking_cancellation.html' - model = Booking - pk_url_kwarg = 'booking_pk' - form_class = BookingCancelForm - - def dispatch(self, request, *args, **kwargs): - self.booking = self.get_object() - return super().dispatch(request, *args, **kwargs) +class CancelView(ViewableAgendaMixin, UpdateView): + error_msg = _('There has been an error sending cancellation notification to form.') def form_valid(self, form): trigger_callback = not form.cleaned_data['disable_trigger'] try: - self.booking.cancel(trigger_callback) + self.object.cancel(trigger_callback) except requests.RequestException as e: - form.add_error(None, _('There has been an error sending cancellation notification to form.')) + form.add_error(None, self.error_msg) form.add_error(None, _('Check this box if you are sure you want to proceed anyway.')) form.show_trigger_checkbox() return self.form_invalid(form) @@ -1852,17 +1846,50 @@ class BookingCancelView(ViewableAgendaMixin, UpdateView): next_url = self.request.POST.get('next') if next_url: return next_url - event = self.booking.event - day = event.start_datetime + day = self.event.start_datetime return reverse( 'chrono-manager-agenda-month-view', - kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month}, + kwargs={'pk': self.agenda.pk, 'year': day.year, 'month': day.month}, ) +class BookingCancelView(CancelView): + template_name = 'chrono/manager_confirm_booking_cancellation.html' + model = Booking + pk_url_kwarg = 'booking_pk' + form_class = BookingCancelForm + + def dispatch(self, request, *args, **kwargs): + self.event = self.get_object().event + return super().dispatch(request, *args, **kwargs) + + booking_cancel = BookingCancelView.as_view() +class EventCancelView(CancelView): + template_name = 'chrono/manager_confirm_event_cancellation.html' + model = Event + pk_url_kwarg = 'event_pk' + form_class = EventCancelForm + error_msg = _('An error occured while sending cancellation notifications to forms.') + + def dispatch(self, request, *args, **kwargs): + self.event = self.get_object() + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['bookings_count'] = self.event.booking_set.filter(cancellation_datetime__isnull=True).count() + context['cancellation_forbidden'] = ( + self.event.booking_set.filter(cancel_callback_url='').exclude(backoffice_url='').exists() + ) + return context + + +event_cancel = EventCancelView.as_view() + + def menu_json(request): label = _('Agendas') json_str = json.dumps( diff --git a/tests/test_manager.py b/tests/test_manager.py index d75cac0..c82bc05 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -3706,3 +3706,79 @@ def test_booking_cancellation_events_agenda(app, admin_user): resp = resp.follow() assert 'Bookings (0/10)' in resp.text + + +def test_event_cancellation(app, admin_user): + agenda = Agenda.objects.create(label='Events', kind='events') + event = Event.objects.create( + label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda + ) + day = event.start_datetime + + login(app) + resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) + assert '0/10 bookings' in resp.text + + resp = resp.click('Cancel') + assert not 'related bookings' in resp.text + + booking = Booking.objects.create(event=event) + booking2 = Booking.objects.create(event=event) + + resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) + assert '2/10 bookings' in resp.text + + resp = resp.click('Cancel') + assert '2 related bookings will also be cancelled.' in resp.text + + resp = resp.form.submit().follow() + assert 'Cancelled' in resp.text + assert '0/10 bookings' in resp.text + assert Booking.objects.filter(event=event, cancellation_datetime__isnull=False).count() == 2 + + +def test_event_cancellation_callback_error(app, admin_user): + agenda = Agenda.objects.create(label='Events', kind='events') + event = Event.objects.create( + label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda + ) + booking = Booking.objects.create(event=event) + booking2 = Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/') + day = event.start_datetime + + def mocked_requests_connection_error(*args, **kwargs): + raise requests.exceptions.ConnectionError('unreachable') + + login(app) + resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) + resp = resp.click('Cancel') + assert resp.form['disable_trigger'].attrs['type'] == 'hidden' + + 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 + mock_send.side_effect = mocked_requests_connection_error + resp = resp.form.submit() + + assert 'error' in resp.text + assert not Booking.objects.filter(cancellation_datetime__isnull=False).exists() + + resp.form['disable_trigger'] = True + resp = resp.form.submit() + assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 2 + + +def test_event_cancellation_forbidden(app, admin_user): + agenda = Agenda.objects.create(label='Events', kind='events') + event = Event.objects.create( + label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda + ) + booking = Booking.objects.create(event=event) + booking2 = Booking.objects.create(event=event, backoffice_url='http://example.org/backoffice/xx/') + day = event.start_datetime + + login(app) + resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) + resp = resp.click('Cancel') + assert 'event has bookings with no callback url configured' in resp.text + assert 'Proceed with cancellation' not in resp.text -- 2.20.1