Projet

Général

Profil

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

Valentin Deniaud, 12 août 2020 12:32

Télécharger (35,1 ko)

Voir les différences:

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
chrono/agendas/management/commands/cancel_events.py
1
# chrono - agendas system
2
# Copyright (C) 2020  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from datetime import timedelta
18

  
19
from requests import RequestException
20

  
21
from django.core.management.base import BaseCommand
22
from django.db import transaction
23
from django.utils import timezone
24

  
25
from chrono.agendas.models import Event, EventCancellationReport
26

  
27

  
28
class Command(BaseCommand):
29
    help = 'Cancel events and related bookings'
30

  
31
    def handle(self, **options):
32
        events_to_cancel = list(Event.objects.filter(cancellation_scheduled=True))
33

  
34
        # prevent overlapping cron conflicts in case actual cancelling takes a long time
35
        for event in events_to_cancel:
36
            event.cancellation_scheduled = False
37
            event.save()
38

  
39
        for event in events_to_cancel:
40
            errors = {}
41
            bookings = []
42
            for booking in event.booking_set.filter(cancellation_datetime__isnull=True).all():
43
                try:
44
                    booking.cancel()
45
                except RequestException as e:
46
                    bookings.append(booking)
47
                    errors[booking.pk] = str(e)
48

  
49
            if not errors:
50
                event.cancelled = True
51
                event.save()
52
            else:
53
                with transaction.atomic():
54
                    report = EventCancellationReport.objects.create(event=event, booking_errors=errors)
55
                    report.bookings.set(bookings)
56

  
57
        # clean old reports
58
        EventCancellationReport.objects.filter(timestamp__lt=timezone.now() - timedelta(days=30)).delete()
chrono/agendas/migrations/0056_auto_20200811_1611.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-08-11 14:11
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import django.db.models.deletion
7
import jsonfield.fields
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    dependencies = [
13
        ('agendas', '0055_booking_cancel_callback_url'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='EventCancellationReport',
19
            fields=[
20
                (
21
                    'id',
22
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
23
                ),
24
                ('timestamp', models.DateTimeField(auto_now_add=True)),
25
                ('seen', models.BooleanField(default=False)),
26
                ('booking_errors', jsonfield.fields.JSONField(default=dict)),
27
                ('bookings', models.ManyToManyField(to='agendas.Booking')),
28
            ],
29
            options={'ordering': ['-timestamp'],},
30
        ),
31
        migrations.AddField(
32
            model_name='event', name='cancellation_scheduled', field=models.BooleanField(default=False),
33
        ),
34
        migrations.AddField(
35
            model_name='event',
36
            name='cancelled',
37
            field=models.BooleanField(
38
                default=False, help_text="Cancel this event so that it won't be bookable anymore."
39
            ),
40
        ),
41
        migrations.AddField(
42
            model_name='eventcancellationreport',
43
            name='event',
44
            field=models.ForeignKey(
45
                on_delete=django.db.models.deletion.CASCADE,
46
                related_name='cancellation_reports',
47
                to='agendas.Event',
48
            ),
49
        ),
50
    ]
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
    )
780
    cancellation_scheduled = models.BooleanField(default=False)
776 781
    meeting_type = models.ForeignKey(MeetingType, null=True, on_delete=models.CASCADE)
777 782
    desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE)
778 783
    resources = models.ManyToManyField('Resource')
......
786 791
            return self.label
787 792
        return date_format(localtime(self.start_datetime), format='DATETIME_FORMAT')
788 793

  
794
    @functional.cached_property
795
    def cancellation_status(self):
796
        if self.cancelled:
797
            return _('Cancelled')
798
        if self.cancellation_scheduled:
799
            return _('Cancellation in progress')
800

  
789 801
    def save(self, *args, **kwargs):
790 802
        assert self.agenda.kind != 'virtual', "an event can't reference a virtual agenda"
791 803
        assert not (self.slug and self.slug.isdigit()), 'slug cannot be a number'
......
932 944

  
933 945
        return new_event
934 946

  
947
    def cancel(self, cancel_bookings=True):
948
        bookings_to_cancel = self.booking_set.filter(cancellation_datetime__isnull=True).all()
949
        if cancel_bookings and bookings_to_cancel.exclude(cancel_callback_url='').exists():
950
            # booking cancellation needs network calls, schedule it for later
951
            self.cancellation_scheduled = True
952
            self.save()
953
        else:
954
            with transaction.atomic():
955
                for booking in bookings_to_cancel:
956
                    booking.cancel(trigger_callback=False)
957
                self.cancelled = True
958
                self.save()
959

  
935 960

  
936 961
class Booking(models.Model):
937 962
    event = models.ForeignKey(Event, on_delete=models.CASCADE)
......
1433 1458
    def as_interval(self):
1434 1459
        '''Simplify insertion into IntervalSet'''
1435 1460
        return Interval(self.start_datetime, self.end_datetime)
1461

  
1462

  
1463
class EventCancellationReport(models.Model):
1464
    event = models.ForeignKey(Event, related_name='cancellation_reports', on_delete=models.CASCADE)
1465
    timestamp = models.DateTimeField(auto_now_add=True)
1466
    seen = models.BooleanField(default=False)
1467
    bookings = models.ManyToManyField(Booking)
1468
    booking_errors = JSONField()
1469

  
1470
    def __str__(self):
1471
        return '%s - %s' % (self.timestamp.strftime('%Y-%m-%d %H:%M:%S'), self.event)
1472

  
1473
    class Meta:
1474
        ordering = ['-timestamp']
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
        fields = [
119
            'agenda',
120
            'start_datetime',
121
            'duration',
122
            'publication_date',
123
            'places',
124
            'waiting_list_places',
125
            'label',
126
            'description',
127
            'pricing',
128
            'url',
129
        ]
119 130

  
120 131

  
121 132
class EventForm(forms.ModelForm):
......
126 137
            'start_datetime': DateTimeWidget(),
127 138
            'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
128 139
        }
129
        exclude = ['full', 'meeting_type', 'desk', 'resources']
140
        fields = [
141
            'agenda',
142
            'start_datetime',
143
            'duration',
144
            'publication_date',
145
            'places',
146
            'waiting_list_places',
147
            'label',
148
            'slug',
149
            'description',
150
            'pricing',
151
            'url',
152
        ]
130 153

  
131 154

  
132 155
class AgendaResourceForm(forms.Form):
......
473 496
    class Meta:
474 497
        model = Booking
475 498
        fields = []
499

  
500

  
501
class EventCancelForm(forms.ModelForm):
502
    class Meta:
503
        model = Event
504
        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

  
32
li.new-report {
33
	font-weight: bold;
34
}
35

  
28 36
li span.duration {
29 37
	font-size: 80%;
30 38
}
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.cancellation_status %}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.cancellation_status %}
13
    {{ event.cancellation_status }}
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.cancellation_status %}
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
  {% if cancel_bookings %}
25
  {% blocktrans trimmed count count=bookings_count %}
26
  {{ count }} related booking will also be cancelled.
27
  {% plural %}
28
  {{ count }} related bookings will also be cancelled.
29
  {% endblocktrans %}
30
  {% else %}
31
  {% trans "Related bookings will have to be manually cancelled if needed." %}
32
  {% endif %}
33
  {% endif %}
34
  </p>
35
  <input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
36
  {{ form.as_p }}
37
  <div class="buttons">
38
    <button class="delete-button">{% trans "Proceed with cancellation" %}</button>
39
    <a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a>
40
  </div>
41
  {% endif %}
42
</form>
43
{% endblock %}
chrono/manager/templates/chrono/manager_event_cancellation_report.html
1
{% extends "chrono/manager_agenda_view.html" %}
2
{% load i18n %}
3

  
4
{% block page-title-extra-label %}
5
{{ block.super }} - {% trans "Cancellation error report" %}
6
{% endblock %}
7

  
8
{% block breadcrumb %}
9
{{ block.super }}
10
<a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.id %}">{% trans "Cancellation error reports" %}</a>
11
{% endblock %}
12

  
13
{% block appbar %}
14
<h2>{% trans "Cancellation error report:" %} {{ report }}</h2>
15
{% block actions %}
16
<a rel="popup" href="{% url 'chrono-manager-event-cancel' pk=agenda.pk event_pk=report.event.pk %}?force_cancellation=True">{% trans "Force cancellation" %}</a>
17
{% endblock %}
18
{% endblock %}
19

  
20
{% block content %}
21
<p>{% trans "Cancellation failed for the following bookings:" %}</p>
22
<ul>
23
{% for booking, error in errors.items %}
24
<li><a href="{{ booking.backoffice_url }}">{{ booking.events_display }}</a>: {{ error }}</li>
25
{% endfor %}
26
</ul>
27
{% endblock %}
chrono/manager/templates/chrono/manager_event_cancellation_report_notice.html
1
{% load i18n %}
2

  
3
{% for report in cancellation_reports %}
4
<div class="warningnotice">
5
<p>
6
{% blocktrans with event=report.event %}Errors occured during cancellation of event "{{ event }}".{% endblocktrans %}
7
<a href="{% url 'chrono-manager-event-cancellation-report' pk=agenda.pk report_pk=report.pk %}">{% trans "Details" %}</a>
8
</p>
9
</div>
10
{% endfor %}
chrono/manager/templates/chrono/manager_event_cancellation_reports.html
1
{% extends "chrono/manager_agenda_view.html" %}
2
{% load i18n %}
3

  
4
{% block page-title-extra-label %}
5
{{ block.super }} - {% trans "Cancellation error reports" %}
6
{% endblock %}
7

  
8
{% block breadcrumb %}
9
{{ block.super }}
10
<a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.pk %}">{% trans "Cancellation error reports" %}</a>
11
{% endblock %}
12

  
13
{% block appbar %}
14
<h2>{% trans "Cancellation error reports" %}</h2>
15
{% endblock %}
16

  
17
{% block content %}
18
<ul>
19
{% for report in cancellation_reports %}
20
<li {% if not report.seen %}class="new-report"{% endif %}>
21
  <a href="{% url 'chrono-manager-event-cancellation-report' pk=agenda.pk report_pk=report.pk %}">{{ report }}</a>
22
  ({% blocktrans count count=report.bookings.count %}{{ count }} failure{% plural %}{{ count }} failures{% endblocktrans %})
23
</li>
24
{% empty %}
25
<li>{% trans "No cancellation error report to show." %}</li>
26
{% endfor %}
27
</ul>
28
{% endblock %}
chrono/manager/templates/chrono/manager_event_detail.html
17 17
{% endblock %}
18 18

  
19 19
{% block appbar %}
20
{% if object.label %}<h2>{{ object.label }} — {{object.start_datetime|date:"DATETIME_FORMAT"}}</h2>
21
{% else %}<h2>{{ object.start_datetime|date:"DATETIME_FORMAT"}}</h2>
20
<h2>
21
{% if object.label %}
22
{{ object.label }} — {{object.start_datetime|date:"DATETIME_FORMAT"}}
23
{% else %}
24
{{ object.start_datetime|date:"DATETIME_FORMAT"}}
22 25
{% endif %}
26
{% if object.cancellation_status %}
27
({{ object.cancellation_status }})
28
{% endif %}
29
</h2>
23 30
<span class="actions">
24 31
{% if user_can_manage %}
25 32
<a rel="popup" href="{% url 'chrono-manager-event-delete' pk=object.agenda.id event_pk=object.id %}">{% trans 'Delete' %}</a>
33
<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>
26 34
<a href="{% url 'chrono-manager-event-edit' pk=agenda.id event_pk=object.id %}">{% trans "Options" %}</a>
27 35
{% endif %}
28 36
</span>
chrono/manager/templates/chrono/manager_events_agenda_month_view.html
2 2
{% load i18n %}
3 3

  
4 4
{% block actions %}
5
<a class="extra-actions-menu-opener"></a>
6
{{ block.super }}
5 7
<a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
8
<ul class="extra-actions-menu">
9
  <li><a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.pk %}">{% trans 'Cancellation error reports' %}</a></li>
10
</ul>
6 11
{% endblock %}
7 12

  
8 13
{% block content %}
9 14
<div class="section">
10 15
<h3>{% trans "Events" %}</h3>
16
{% include 'chrono/manager_event_cancellation_report_notice.html' %}
11 17
<div>
12 18
{% if object_list %}
13 19
  <ul class="objects-list single-links">
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
    ),
96
    url(
97
        r'^agendas/(?P<pk>\d+)/event_cancellation_report/(?P<report_pk>\d+)/$',
98
        views.event_cancellation_report,
99
        name='chrono-manager-event-cancellation-report',
100
    ),
101
    url(
102
        r'^agendas/(?P<pk>\d+)/event_cancellation_reports/$',
103
        views.event_cancellation_report_list,
104
        name='chrono-manager-event-cancellation-report-list',
105
    ),
91 106
    url(
92 107
        r'^agendas/(?P<pk>\d+)/add-resource/$',
93 108
        views.agenda_add_resource,
chrono/manager/views.py
62 62
    VirtualMember,
63 63
    Resource,
64 64
    Category,
65
    EventCancellationReport,
65 66
)
66 67

  
67 68
from .forms import (
......
88 89
    CategoryAddForm,
89 90
    CategoryEditForm,
90 91
    BookingCancelForm,
92
    EventCancelForm,
91 93
)
92 94
from .utils import import_site
93 95

  
......
952 954
        context = super(AgendaMonthView, self).get_context_data(**kwargs)
953 955
        if self.agenda.kind == 'meetings':
954 956
            context['single_desk'] = bool(self.agenda.prefetched_desks)
957
        elif self.agenda.kind == 'events':
958
            context['cancellation_reports'] = EventCancellationReport.objects.filter(
959
                event__agenda=self.agenda, seen=False,
960
            ).all()
955 961
        return context
956 962

  
957 963
    def get_previous_month_url(self):
......
1863 1869
booking_cancel = BookingCancelView.as_view()
1864 1870

  
1865 1871

  
1872
class EventCancelView(ViewableAgendaMixin, UpdateView):
1873
    template_name = 'chrono/manager_confirm_event_cancellation.html'
1874
    model = Event
1875
    pk_url_kwarg = 'event_pk'
1876
    form_class = EventCancelForm
1877

  
1878
    def dispatch(self, request, *args, **kwargs):
1879
        self.event = self.get_object()
1880
        if self.event.cancellation_status:
1881
            raise PermissionDenied()
1882
        self.cancel_bookings = False if self.request.GET.get('force_cancellation') else True
1883
        return super().dispatch(request, *args, **kwargs)
1884

  
1885
    def form_valid(self, form):
1886
        self.event.cancel(self.cancel_bookings)
1887
        return HttpResponseRedirect(self.get_success_url())
1888

  
1889
    def get_context_data(self, **kwargs):
1890
        context = super().get_context_data(**kwargs)
1891
        context['cancel_bookings'] = self.cancel_bookings
1892
        context['bookings_count'] = self.event.booking_set.filter(cancellation_datetime__isnull=True).count()
1893
        context['cancellation_forbidden'] = (
1894
            self.event.booking_set.filter(cancel_callback_url='').exclude(backoffice_url='').exists()
1895
        )
1896
        return context
1897

  
1898
    def get_success_url(self):
1899
        self.event.refresh_from_db()
1900
        if self.event.cancellation_scheduled:
1901
            messages.info(self.request, _('Event "%s" will be cancelled in a few minutes.') % self.event)
1902
        next_url = self.request.POST.get('next')
1903
        if next_url:
1904
            return next_url
1905
        day = self.event.start_datetime
1906
        return reverse(
1907
            'chrono-manager-agenda-month-view',
1908
            kwargs={'pk': self.event.agenda.pk, 'year': day.year, 'month': day.month},
1909
        )
1910

  
1911

  
1912
event_cancel = EventCancelView.as_view()
1913

  
1914

  
1915
class EventCancellationReportView(ViewableAgendaMixin, DetailView):
1916
    model = EventCancellationReport
1917
    template_name = 'chrono/manager_event_cancellation_report.html'
1918
    context_object_name = 'report'
1919
    pk_url_kwarg = 'report_pk'
1920

  
1921
    def get(self, *args, **kwargs):
1922
        self.report = self.get_object()
1923
        self.report.seen = True
1924
        self.report.save()
1925
        return super().get(*args, **kwargs)
1926

  
1927
    def get_context_data(self, **kwargs):
1928
        context = super().get_context_data(**kwargs)
1929
        bookings = self.report.bookings.all()
1930
        errors = self.report.booking_errors
1931
        context['errors'] = {
1932
            booking: errors[str(booking.pk)] for booking in bookings if str(booking.pk) in errors
1933
        }
1934
        return context
1935

  
1936

  
1937
event_cancellation_report = EventCancellationReportView.as_view()
1938

  
1939

  
1940
class EventCancellationReportListView(ViewableAgendaMixin, ListView):
1941
    model = EventCancellationReport
1942
    context_object_name = 'cancellation_reports'
1943
    template_name = 'chrono/manager_event_cancellation_reports.html'
1944

  
1945

  
1946
event_cancellation_report_list = EventCancellationReportListView.as_view()
1947

  
1948

  
1866 1949
def menu_json(request):
1867 1950
    label = _('Agendas')
1868 1951
    json_str = json.dumps(
tests/test_agendas.py
22 22
    TimePeriodException,
23 23
    TimePeriodExceptionSource,
24 24
    VirtualMember,
25
    EventCancellationReport,
25 26
)
26 27

  
27 28
pytestmark = pytest.mark.django_db
......
1008 1009

  
1009 1010
    assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda1).exists()
1010 1011
    assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda2).exists()
1012

  
1013

  
1014
def test_agendas_cancel_events_command():
1015
    agenda = Agenda.objects.create(label='Events', kind='events')
1016
    event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event')
1017

  
1018
    for i in range(5):
1019
        Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
1020

  
1021
    event.cancellation_scheduled = True
1022
    event.save()
1023

  
1024
    with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
1025
        mock_response = mock.Mock(status_code=200)
1026
        mock_send.return_value = mock_response
1027
        call_command('cancel_events')
1028
        assert mock_send.call_count == 5
1029

  
1030
    assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 5
1031
    event.refresh_from_db()
1032
    assert not event.cancellation_scheduled
1033
    assert event.cancelled
1034

  
1035

  
1036
def test_agendas_cancel_events_command_network_error(freezer):
1037
    freezer.move_to('2020-01-01')
1038
    agenda = Agenda.objects.create(label='Events', kind='events')
1039
    event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event')
1040

  
1041
    def mocked_requests_connection_error(request, **kwargs):
1042
        if 'good' in request.url:
1043
            return mock.Mock(status_code=200)
1044
        raise requests.exceptions.ConnectionError('unreachable')
1045

  
1046
    booking_good_url = Booking.objects.create(event=event, cancel_callback_url='http://good.org/')
1047
    for i in range(5):
1048
        Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
1049

  
1050
    event.cancellation_scheduled = True
1051
    event.save()
1052

  
1053
    with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
1054
        mock_response = mock.Mock(status_code=200)
1055
        mock_send.return_value = mock_response
1056
        mock_send.side_effect = mocked_requests_connection_error
1057
        call_command('cancel_events')
1058
        assert mock_send.call_count == 6
1059

  
1060
    booking_good_url.refresh_from_db()
1061
    assert booking_good_url.cancellation_datetime
1062
    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 5
1063
    event.refresh_from_db()
1064
    assert not event.cancellation_scheduled
1065
    assert not event.cancelled
1066

  
1067
    report = EventCancellationReport.objects.get(event=event)
1068
    assert report.bookings.count() == 5
1069
    assert len(report.booking_errors) == 5
1070

  
1071
    for booking in report.bookings.all():
1072
        assert report.booking_errors[str(booking.pk)] == 'unreachable'
1073

  
1074
    # old reports are automatically removed
1075
    freezer.move_to('2020-03-01')
1076
    call_command('cancel_events')
1077
    assert not EventCancellationReport.objects.exists()
tests/test_manager.py
9 9
import os
10 10

  
11 11
from django.contrib.auth.models import User, Group
12
from django.core.management import call_command
12 13
from django.db import connection
13 14
from django.test.utils import CaptureQueriesContext
14 15
from django.utils.encoding import force_text
......
2652 2653
        app.get(
2653 2654
            '/manage/agendas/%s/%s/%s/' % (agenda.id, event.start_datetime.year, event.start_datetime.month)
2654 2655
        )
2655
        assert len(ctx.captured_queries) == 5
2656
        assert len(ctx.captured_queries) == 6
2656 2657

  
2657 2658
    # current month still doesn't have events
2658 2659
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
......
3697 3698
    resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
3698 3699
    assert 'Bookings (1/10)' in resp.text
3699 3700

  
3700
    resp = resp.click('Cancel')
3701
    resp = resp.click('Cancel', href='bookings/')
3701 3702
    resp = resp.form.submit()
3702 3703
    assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
3703 3704

  
......
3706 3707

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

  
3711

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

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

  
3723
    resp = resp.click('Cancel', href='/cancel')
3724
    assert not 'related bookings' in resp.text
3725

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

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

  
3732
    resp = resp.click('Cancel', href='/cancel')
3733
    assert '2 related bookings will also be cancelled.' in resp.text
3734

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

  
3740

  
3741
def test_event_cancellation_error_report(app, admin_user):
3742
    agenda = Agenda.objects.create(label='Events', kind='events')
3743
    event = Event.objects.create(
3744
        label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda
3745
    )
3746
    day = event.start_datetime
3747

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

  
3751
    for i in range(5):
3752
        Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
3753

  
3754
    login(app)
3755
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3756
    resp = resp.click('Cancellation error reports')
3757
    assert 'No cancellation error' in resp.text
3758

  
3759
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3760
    resp = resp.click('Cancel', href='/cancel')
3761
    resp = resp.form.submit().follow()
3762
    assert 'Cancellation in progress' in resp.text
3763

  
3764
    with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send:
3765
        mock_response = mock.Mock(status_code=200)
3766
        mock_send.return_value = mock_response
3767
        mock_send.side_effect = mocked_requests_connection_error
3768
        call_command('cancel_events')
3769

  
3770
    event.refresh_from_db()
3771
    assert not event.cancelled and not event.cancellation_scheduled
3772
    assert not Booking.objects.filter(cancellation_datetime__isnull=False).exists()
3773

  
3774
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3775
    assert 'Errors occured during cancellation of event "xyz".' in resp.text
3776

  
3777
    # warning doesn't go away
3778
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3779
    assert 'Errors occured during cancellation of event "xyz".' in resp.text
3780

  
3781
    resp = resp.click('Details')
3782
    assert resp.text.count('unreachable') == 5
3783

  
3784
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3785
    assert not 'Errors occured during cancellation of event "xyz".' in resp.text
3786

  
3787
    resp = resp.click('Cancellation error reports')
3788
    assert '(5 failures)' in resp.text
3789

  
3790
    resp = resp.click(str(event))
3791
    resp = resp.click('Force cancellation')
3792
    resp = resp.form.submit().follow()
3793
    event.refresh_from_db()
3794
    assert event.cancelled and not event.cancellation_scheduled
3795
    assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 5
3796

  
3797

  
3798
def test_event_cancellation_forbidden(app, admin_user):
3799
    agenda = Agenda.objects.create(label='Events', kind='events')
3800
    event = Event.objects.create(
3801
        label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda
3802
    )
3803
    booking = Booking.objects.create(event=event)
3804
    booking2 = Booking.objects.create(event=event, backoffice_url='http://example.org/backoffice/xx/')
3805
    day = event.start_datetime
3806

  
3807
    login(app)
3808
    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
3809
    resp = resp.click('Cancel', href='/cancel')
3810
    assert 'event has bookings with no callback url configured' in resp.text
3811
    assert 'Proceed with cancellation' not in resp.text
3709
-