From 07854808c420a1307d41103dad94989bfe691648 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 9 Jul 2020 12:46:13 +0200 Subject: [PATCH 3/3] manager: add event cancellation (#44157) --- .../management/commands/cancel_events.py | 58 ++++++++++ .../migrations/0056_auto_20200811_1611.py | 50 ++++++++ chrono/agendas/models.py | 39 +++++++ chrono/api/views.py | 4 + chrono/manager/forms.py | 33 +++++- chrono/manager/static/css/style.scss | 8 ++ .../chrono/manager_agenda_event_fragment.html | 15 ++- .../manager_confirm_event_cancellation.html | 43 +++++++ .../manager_event_cancellation_report.html | 27 +++++ ...ager_event_cancellation_report_notice.html | 10 ++ .../manager_event_cancellation_reports.html | 28 +++++ .../chrono/manager_event_detail.html | 12 +- .../manager_events_agenda_month_view.html | 6 + chrono/manager/urls.py | 15 +++ chrono/manager/views.py | 83 ++++++++++++++ tests/test_agendas.py | 67 +++++++++++ tests/test_manager.py | 107 +++++++++++++++++- 17 files changed, 597 insertions(+), 8 deletions(-) create mode 100644 chrono/agendas/management/commands/cancel_events.py create mode 100644 chrono/agendas/migrations/0056_auto_20200811_1611.py create mode 100644 chrono/manager/templates/chrono/manager_confirm_event_cancellation.html create mode 100644 chrono/manager/templates/chrono/manager_event_cancellation_report.html create mode 100644 chrono/manager/templates/chrono/manager_event_cancellation_report_notice.html create mode 100644 chrono/manager/templates/chrono/manager_event_cancellation_reports.html diff --git a/chrono/agendas/management/commands/cancel_events.py b/chrono/agendas/management/commands/cancel_events.py new file mode 100644 index 0000000..537a53c --- /dev/null +++ b/chrono/agendas/management/commands/cancel_events.py @@ -0,0 +1,58 @@ +# chrono - agendas system +# Copyright (C) 2020 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from datetime import timedelta + +from requests import RequestException + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from chrono.agendas.models import Event, EventCancellationReport + + +class Command(BaseCommand): + help = 'Cancel events and related bookings' + + def handle(self, **options): + events_to_cancel = list(Event.objects.filter(cancellation_scheduled=True)) + + # prevent overlapping cron conflicts in case actual cancelling takes a long time + for event in events_to_cancel: + event.cancellation_scheduled = False + event.save() + + for event in events_to_cancel: + errors = {} + bookings = [] + for booking in event.booking_set.filter(cancellation_datetime__isnull=True).all(): + try: + booking.cancel() + except RequestException as e: + bookings.append(booking) + errors[booking.pk] = str(e) + + if not errors: + event.cancelled = True + event.save() + else: + with transaction.atomic(): + report = EventCancellationReport.objects.create(event=event, booking_errors=errors) + report.bookings.set(bookings) + + # clean old reports + EventCancellationReport.objects.filter(timestamp__lt=timezone.now() - timedelta(days=30)).delete() diff --git a/chrono/agendas/migrations/0056_auto_20200811_1611.py b/chrono/agendas/migrations/0056_auto_20200811_1611.py new file mode 100644 index 0000000..6511f54 --- /dev/null +++ b/chrono/agendas/migrations/0056_auto_20200811_1611.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-08-11 14:11 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0055_booking_cancel_callback_url'), + ] + + operations = [ + migrations.CreateModel( + name='EventCancellationReport', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('seen', models.BooleanField(default=False)), + ('booking_errors', jsonfield.fields.JSONField(default=dict)), + ('bookings', models.ManyToManyField(to='agendas.Booking')), + ], + options={'ordering': ['-timestamp'],}, + ), + migrations.AddField( + model_name='event', name='cancellation_scheduled', field=models.BooleanField(default=False), + ), + 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." + ), + ), + migrations.AddField( + model_name='eventcancellationreport', + name='event', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='cancellation_reports', + to='agendas.Event', + ), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 665d8de..dcb9034 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,10 @@ 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.") + ) + cancellation_scheduled = models.BooleanField(default=False) 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') @@ -786,6 +791,13 @@ class Event(models.Model): return self.label return date_format(localtime(self.start_datetime), format='DATETIME_FORMAT') + @functional.cached_property + def cancellation_status(self): + if self.cancelled: + return _('Cancelled') + if self.cancellation_scheduled: + return _('Cancellation in progress') + def save(self, *args, **kwargs): assert self.agenda.kind != 'virtual', "an event can't reference a virtual agenda" assert not (self.slug and self.slug.isdigit()), 'slug cannot be a number' @@ -932,6 +944,19 @@ class Event(models.Model): return new_event + def cancel(self, cancel_bookings=True): + bookings_to_cancel = self.booking_set.filter(cancellation_datetime__isnull=True).all() + if cancel_bookings and bookings_to_cancel.exclude(cancel_callback_url='').exists(): + # booking cancellation needs network calls, schedule it for later + self.cancellation_scheduled = True + self.save() + else: + with transaction.atomic(): + for booking in bookings_to_cancel: + booking.cancel(trigger_callback=False) + self.cancelled = True + self.save() + class Booking(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) @@ -1433,3 +1458,17 @@ class TimePeriodException(models.Model): def as_interval(self): '''Simplify insertion into IntervalSet''' return Interval(self.start_datetime, self.end_datetime) + + +class EventCancellationReport(models.Model): + event = models.ForeignKey(Event, related_name='cancellation_reports', on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + seen = models.BooleanField(default=False) + bookings = models.ManyToManyField(Booking) + booking_errors = JSONField() + + def __str__(self): + return '%s - %s' % (self.timestamp.strftime('%Y-%m-%d %H:%M:%S'), self.event) + + class Meta: + ordering = ['-timestamp'] 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..1c336e8 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -115,7 +115,18 @@ 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'] + fields = [ + 'agenda', + 'start_datetime', + 'duration', + 'publication_date', + 'places', + 'waiting_list_places', + 'label', + 'description', + 'pricing', + 'url', + ] class EventForm(forms.ModelForm): @@ -126,7 +137,19 @@ class EventForm(forms.ModelForm): 'start_datetime': DateTimeWidget(), 'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), } - exclude = ['full', 'meeting_type', 'desk', 'resources'] + fields = [ + 'agenda', + 'start_datetime', + 'duration', + 'publication_date', + 'places', + 'waiting_list_places', + 'label', + 'slug', + 'description', + 'pricing', + 'url', + ] class AgendaResourceForm(forms.Form): @@ -473,3 +496,9 @@ class BookingCancelForm(forms.ModelForm): class Meta: model = Booking fields = [] + + +class EventCancelForm(forms.ModelForm): + class Meta: + model = Event + fields = [] diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss index 75ca838..6a59ad5 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -25,6 +25,14 @@ li.full { background: #f8f8fe; } +li.cancelled span.event-info { + text-decoration: line-through; +} + +li.new-report { + font-weight: bold; +} + 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..ae0fabe 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.cancellation_status %} + {{ event.cancellation_status }} + {% 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.cancellation_status %} + {% 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..1744f79 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_confirm_event_cancellation.html @@ -0,0 +1,43 @@ +{% 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 %} + {% if cancel_bookings %} + {% blocktrans trimmed count count=bookings_count %} + {{ count }} related booking will also be cancelled. + {% plural %} + {{ count }} related bookings will also be cancelled. + {% endblocktrans %} + {% else %} + {% trans "Related bookings will have to be manually cancelled if needed." %} + {% endif %} + {% endif %} +

    + + {{ form.as_p }} +
    + + {% trans 'Abort' %} +
    + {% endif %} +
    +{% endblock %} diff --git a/chrono/manager/templates/chrono/manager_event_cancellation_report.html b/chrono/manager/templates/chrono/manager_event_cancellation_report.html new file mode 100644 index 0000000..b9bc8ae --- /dev/null +++ b/chrono/manager/templates/chrono/manager_event_cancellation_report.html @@ -0,0 +1,27 @@ +{% extends "chrono/manager_agenda_view.html" %} +{% load i18n %} + +{% block page-title-extra-label %} +{{ block.super }} - {% trans "Cancellation error report" %} +{% endblock %} + +{% block breadcrumb %} +{{ block.super }} +{% trans "Cancellation error reports" %} +{% endblock %} + +{% block appbar %} +

    {% trans "Cancellation error report:" %} {{ report }}

    +{% block actions %} +{% trans "Force cancellation" %} +{% endblock %} +{% endblock %} + +{% block content %} +

    {% trans "Cancellation failed for the following bookings:" %}

    + +{% endblock %} diff --git a/chrono/manager/templates/chrono/manager_event_cancellation_report_notice.html b/chrono/manager/templates/chrono/manager_event_cancellation_report_notice.html new file mode 100644 index 0000000..53dd000 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_event_cancellation_report_notice.html @@ -0,0 +1,10 @@ +{% load i18n %} + +{% for report in cancellation_reports %} +
    +

    +{% blocktrans with event=report.event %}Errors occured during cancellation of event "{{ event }}".{% endblocktrans %} +{% trans "Details" %} +

    +
    +{% endfor %} diff --git a/chrono/manager/templates/chrono/manager_event_cancellation_reports.html b/chrono/manager/templates/chrono/manager_event_cancellation_reports.html new file mode 100644 index 0000000..e8ea996 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_event_cancellation_reports.html @@ -0,0 +1,28 @@ +{% extends "chrono/manager_agenda_view.html" %} +{% load i18n %} + +{% block page-title-extra-label %} +{{ block.super }} - {% trans "Cancellation error reports" %} +{% endblock %} + +{% block breadcrumb %} +{{ block.super }} +{% trans "Cancellation error reports" %} +{% endblock %} + +{% block appbar %} +

    {% trans "Cancellation error reports" %}

    +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/chrono/manager/templates/chrono/manager_event_detail.html b/chrono/manager/templates/chrono/manager_event_detail.html index d589d66..c96e9a1 100644 --- a/chrono/manager/templates/chrono/manager_event_detail.html +++ b/chrono/manager/templates/chrono/manager_event_detail.html @@ -17,12 +17,20 @@ {% endblock %} {% block appbar %} -{% if object.label %}

    {{ object.label }} — {{object.start_datetime|date:"DATETIME_FORMAT"}}

    -{% else %}

    {{ object.start_datetime|date:"DATETIME_FORMAT"}}

    +

    +{% if object.label %} +{{ object.label }} — {{object.start_datetime|date:"DATETIME_FORMAT"}} +{% else %} +{{ object.start_datetime|date:"DATETIME_FORMAT"}} {% endif %} +{% if object.cancellation_status %} +({{ object.cancellation_status }}) +{% endif %} +

    {% if user_can_manage %} {% trans 'Delete' %} +{% trans "Cancel" %} {% trans "Options" %} {% endif %} diff --git a/chrono/manager/templates/chrono/manager_events_agenda_month_view.html b/chrono/manager/templates/chrono/manager_events_agenda_month_view.html index b394ce7..43f6be0 100644 --- a/chrono/manager/templates/chrono/manager_events_agenda_month_view.html +++ b/chrono/manager/templates/chrono/manager_events_agenda_month_view.html @@ -2,12 +2,18 @@ {% load i18n %} {% block actions %} + +{{ block.super }} {% trans 'Open events' %} + {% endblock %} {% block content %}

    {% trans "Events" %}

    +{% include 'chrono/manager_event_cancellation_report_notice.html' %}
    {% if object_list %}