manager: add event cancellation (#44157)
This commit is contained in:
parent
677ec53426
commit
bde66b58e0
|
@ -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()
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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']
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue