0003-manager-add-event-cancellation-44157.patch
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 |
- |