manager: add event cancellation (#44157)

This commit is contained in:
Valentin Deniaud 2020-07-09 12:46:13 +02:00
parent 677ec53426
commit bde66b58e0
17 changed files with 597 additions and 8 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
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()

View File

@ -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',
),
),
]

View File

@ -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']

View File

@ -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(

View File

@ -114,7 +114,18 @@ class NewEventForm(forms.ModelForm):
'agenda': forms.HiddenInput(),
'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',
]
field_classes = {
'start_datetime': SplitDateTimeField,
}
@ -127,10 +138,22 @@ class EventForm(forms.ModelForm):
'agenda': forms.HiddenInput(),
'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
}
fields = [
'agenda',
'start_datetime',
'duration',
'publication_date',
'places',
'waiting_list_places',
'label',
'slug',
'description',
'pricing',
'url',
]
field_classes = {
'start_datetime': SplitDateTimeField,
}
exclude = ['full', 'meeting_type', 'desk', 'resources']
class AgendaResourceForm(forms.Form):
@ -479,3 +502,9 @@ class BookingCancelForm(forms.ModelForm):
class Meta:
model = Booking
fields = []
class EventCancelForm(forms.ModelForm):
class Meta:
model = Event
fields = []

View File

@ -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%;
}

View File

@ -1,6 +1,7 @@
{% load i18n %}
<li class="{% if event.booked_places_count > event.places %}overbooking{% endif %}
{% if event.main_list_full %}full{% endif %}
{% if event.cancellation_status %}cancelled{% endif %}
{% if not event.in_bookable_period %}not-{% endif %}bookable"
{% if event.places %}
data-total="{{ event.places }}" data-booked="{{ event.booked_places_count }}"
@ -8,7 +9,12 @@
data-total="{{ event.waiting_list_places }}" data-booked="{{ event.waiting_list_count }}"
{% endif %}
><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 %}">
{% if event.main_list_full %}<span class="full tag">{% trans "Full" %}</span>{% endif %}
{% if event.cancellation_status %}
{{ event.cancellation_status }}
{% elif event.main_list_full %}
<span class="full tag">{% trans "Full" %}</span>
{% endif %}
<span class="event-info">
{% if settings_view %}
{% if event.label %}{{ event.label }} {% endif %}[{% trans "identifier:" %} {{ event.slug }}]
{% else %}
@ -34,7 +40,12 @@
{% if not event.in_bookable_period %}
({% trans "out of bookable period" %})
{% endif %}
</span>
</a>
{% 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 %}
{% 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>
{% elif not event.cancellation_status %}
<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>
{% endif %}
<span class="occupation-bar"></span>
</li>

View File

@ -0,0 +1,43 @@
{% extends "chrono/manager_home.html" %}
{% load i18n %}
{% block appbar %}
<h2>{{ view.model.get_verbose_name }}</h2>
{% endblock %}
{% block content %}
<form method="post">
{% if cancellation_forbidden %}
<div class="warningnotice">
{% 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 %}
</div>
{% else %}
{% csrf_token %}
<p>
{% 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 %}
</p>
<input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
{{ form.as_p }}
<div class="buttons">
<button class="delete-button">{% trans "Proceed with cancellation" %}</button>
<a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a>
</div>
{% endif %}
</form>
{% endblock %}

View File

@ -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 }}
<a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.id %}">{% trans "Cancellation error reports" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Cancellation error report:" %} {{ report }}</h2>
{% block actions %}
<a rel="popup" href="{% url 'chrono-manager-event-cancel' pk=agenda.pk event_pk=report.event.pk %}?force_cancellation=True">{% trans "Force cancellation" %}</a>
{% endblock %}
{% endblock %}
{% block content %}
<p>{% trans "Cancellation failed for the following bookings:" %}</p>
<ul>
{% for booking, error in errors.items %}
<li><a href="{{ booking.backoffice_url }}">{{ booking.events_display }}</a>: {{ error }}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% load i18n %}
{% for report in cancellation_reports %}
<div class="warningnotice">
<p>
{% blocktrans with event=report.event %}Errors occured during cancellation of event "{{ event }}".{% endblocktrans %}
<a href="{% url 'chrono-manager-event-cancellation-report' pk=agenda.pk report_pk=report.pk %}">{% trans "Details" %}</a>
</p>
</div>
{% endfor %}

View File

@ -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 }}
<a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.pk %}">{% trans "Cancellation error reports" %}</a>
{% endblock %}
{% block appbar %}
<h2>{% trans "Cancellation error reports" %}</h2>
{% endblock %}
{% block content %}
<ul>
{% for report in cancellation_reports %}
<li {% if not report.seen %}class="new-report"{% endif %}>
<a href="{% url 'chrono-manager-event-cancellation-report' pk=agenda.pk report_pk=report.pk %}">{{ report }}</a>
({% blocktrans count count=report.bookings.count %}{{ count }} failure{% plural %}{{ count }} failures{% endblocktrans %})
</li>
{% empty %}
<li>{% trans "No cancellation error report to show." %}</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -17,12 +17,20 @@
{% endblock %}
{% block appbar %}
{% if object.label %}<h2>{{ object.label }} — {{object.start_datetime|date:"DATETIME_FORMAT"}}</h2>
{% else %}<h2>{{ object.start_datetime|date:"DATETIME_FORMAT"}}</h2>
<h2>
{% 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 %}
</h2>
<span class="actions">
{% if user_can_manage %}
<a rel="popup" href="{% url 'chrono-manager-event-delete' pk=object.agenda.id event_pk=object.id %}">{% trans 'Delete' %}</a>
<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>
<a href="{% url 'chrono-manager-event-edit' pk=agenda.id event_pk=object.id %}">{% trans "Options" %}</a>
{% endif %}
</span>

View File

@ -2,12 +2,18 @@
{% load i18n %}
{% block actions %}
<a class="extra-actions-menu-opener"></a>
{{ block.super }}
<a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
<ul class="extra-actions-menu">
<li><a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.pk %}">{% trans 'Cancellation error reports' %}</a></li>
</ul>
{% endblock %}
{% block content %}
<div class="section">
<h3>{% trans "Events" %}</h3>
{% include 'chrono/manager_event_cancellation_report_notice.html' %}
<div>
{% if object_list %}
<ul class="objects-list single-links">

View File

@ -88,6 +88,21 @@ urlpatterns = [
views.event_delete,
name='chrono-manager-event-delete',
),
url(
r'^agendas/(?P<pk>\d+)/events/(?P<event_pk>\d+)/cancel$',
views.event_cancel,
name='chrono-manager-event-cancel',
),
url(
r'^agendas/(?P<pk>\d+)/event_cancellation_report/(?P<report_pk>\d+)/$',
views.event_cancellation_report,
name='chrono-manager-event-cancellation-report',
),
url(
r'^agendas/(?P<pk>\d+)/event_cancellation_reports/$',
views.event_cancellation_report_list,
name='chrono-manager-event-cancellation-report-list',
),
url(
r'^agendas/(?P<pk>\d+)/add-resource/$',
views.agenda_add_resource,

View File

@ -62,6 +62,7 @@ from chrono.agendas.models import (
VirtualMember,
Resource,
Category,
EventCancellationReport,
)
from .forms import (
@ -88,6 +89,7 @@ from .forms import (
CategoryAddForm,
CategoryEditForm,
BookingCancelForm,
EventCancelForm,
)
from .utils import import_site
@ -952,6 +954,10 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
context = super(AgendaMonthView, self).get_context_data(**kwargs)
if self.agenda.kind == 'meetings':
context['single_desk'] = bool(self.agenda.prefetched_desks)
elif self.agenda.kind == 'events':
context['cancellation_reports'] = EventCancellationReport.objects.filter(
event__agenda=self.agenda, seen=False,
).all()
return context
def get_previous_month_url(self):
@ -1863,6 +1869,83 @@ class BookingCancelView(ViewableAgendaMixin, UpdateView):
booking_cancel = BookingCancelView.as_view()
class EventCancelView(ViewableAgendaMixin, UpdateView):
template_name = 'chrono/manager_confirm_event_cancellation.html'
model = Event
pk_url_kwarg = 'event_pk'
form_class = EventCancelForm
def dispatch(self, request, *args, **kwargs):
self.event = self.get_object()
if self.event.cancellation_status:
raise PermissionDenied()
self.cancel_bookings = False if self.request.GET.get('force_cancellation') else True
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
self.event.cancel(self.cancel_bookings)
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_bookings'] = self.cancel_bookings
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
def get_success_url(self):
self.event.refresh_from_db()
if self.event.cancellation_scheduled:
messages.info(self.request, _('Event "%s" will be cancelled in a few minutes.') % self.event)
next_url = self.request.POST.get('next')
if next_url:
return next_url
day = self.event.start_datetime
return reverse(
'chrono-manager-agenda-month-view',
kwargs={'pk': self.event.agenda.pk, 'year': day.year, 'month': day.month},
)
event_cancel = EventCancelView.as_view()
class EventCancellationReportView(ViewableAgendaMixin, DetailView):
model = EventCancellationReport
template_name = 'chrono/manager_event_cancellation_report.html'
context_object_name = 'report'
pk_url_kwarg = 'report_pk'
def get(self, *args, **kwargs):
self.report = self.get_object()
self.report.seen = True
self.report.save()
return super().get(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
bookings = self.report.bookings.all()
errors = self.report.booking_errors
context['errors'] = {
booking: errors[str(booking.pk)] for booking in bookings if str(booking.pk) in errors
}
return context
event_cancellation_report = EventCancellationReportView.as_view()
class EventCancellationReportListView(ViewableAgendaMixin, ListView):
model = EventCancellationReport
context_object_name = 'cancellation_reports'
template_name = 'chrono/manager_event_cancellation_reports.html'
event_cancellation_report_list = EventCancellationReportListView.as_view()
def menu_json(request):
label = _('Agendas')
json_str = json.dumps(

View File

@ -22,6 +22,7 @@ from chrono.agendas.models import (
TimePeriodException,
TimePeriodExceptionSource,
VirtualMember,
EventCancellationReport,
)
pytestmark = pytest.mark.django_db
@ -1008,3 +1009,69 @@ def test_agenda_virtual_duplicate():
assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda1).exists()
assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda2).exists()
def test_agendas_cancel_events_command():
agenda = Agenda.objects.create(label='Events', kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event')
for i in range(5):
Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
event.cancellation_scheduled = True
event.save()
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
call_command('cancel_events')
assert mock_send.call_count == 5
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 5
event.refresh_from_db()
assert not event.cancellation_scheduled
assert event.cancelled
def test_agendas_cancel_events_command_network_error(freezer):
freezer.move_to('2020-01-01')
agenda = Agenda.objects.create(label='Events', kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event')
def mocked_requests_connection_error(request, **kwargs):
if 'good' in request.url:
return mock.Mock(status_code=200)
raise requests.exceptions.ConnectionError('unreachable')
booking_good_url = Booking.objects.create(event=event, cancel_callback_url='http://good.org/')
for i in range(5):
Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
event.cancellation_scheduled = True
event.save()
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
call_command('cancel_events')
assert mock_send.call_count == 6
booking_good_url.refresh_from_db()
assert booking_good_url.cancellation_datetime
assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 5
event.refresh_from_db()
assert not event.cancellation_scheduled
assert not event.cancelled
report = EventCancellationReport.objects.get(event=event)
assert report.bookings.count() == 5
assert len(report.booking_errors) == 5
for booking in report.bookings.all():
assert report.booking_errors[str(booking.pk)] == 'unreachable'
# old reports are automatically removed
freezer.move_to('2020-03-01')
call_command('cancel_events')
assert not EventCancellationReport.objects.exists()

View File

@ -9,6 +9,7 @@ import mock
import os
from django.contrib.auth.models import User, Group
from django.core.management import call_command
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils.encoding import force_text
@ -2652,7 +2653,7 @@ def test_agenda_events_month_view(app, admin_user):
app.get(
'/manage/agendas/%s/%s/%s/' % (agenda.id, event.start_datetime.year, event.start_datetime.month)
)
assert len(ctx.captured_queries) == 5
assert len(ctx.captured_queries) == 6
# current month still doesn't have events
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
@ -3697,7 +3698,7 @@ def test_booking_cancellation_events_agenda(app, admin_user):
resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
assert 'Bookings (1/10)' in resp.text
resp = resp.click('Cancel')
resp = resp.click('Cancel', href='bookings/')
resp = resp.form.submit()
assert resp.location.endswith('/manage/agendas/%s/events/%s/' % (agenda.id, event.id))
@ -3706,3 +3707,105 @@ 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', href='/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', href='/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_error_report(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
def mocked_requests_connection_error(*args, **kwargs):
raise requests.exceptions.ConnectionError('unreachable')
for i in range(5):
Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/')
login(app)
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
resp = resp.click('Cancellation error reports')
assert 'No cancellation error' in resp.text
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
resp = resp.click('Cancel', href='/cancel')
resp = resp.form.submit().follow()
assert 'Cancellation in progress' in resp.text
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
call_command('cancel_events')
event.refresh_from_db()
assert not event.cancelled and not event.cancellation_scheduled
assert not Booking.objects.filter(cancellation_datetime__isnull=False).exists()
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
assert 'Errors occured during cancellation of event "xyz".' in resp.text
# warning doesn't go away
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
assert 'Errors occured during cancellation of event "xyz".' in resp.text
resp = resp.click('Details')
assert resp.text.count('unreachable') == 5
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month))
assert not 'Errors occured during cancellation of event "xyz".' in resp.text
resp = resp.click('Cancellation error reports')
assert '(5 failures)' in resp.text
resp = resp.click(str(event))
resp = resp.click('Force cancellation')
resp = resp.form.submit().follow()
event.refresh_from_db()
assert event.cancelled and not event.cancellation_scheduled
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 5
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', href='/cancel')
assert 'event has bookings with no callback url configured' in resp.text
assert 'Proceed with cancellation' not in resp.text