0001-manager-add-event-cancellation-44157.patch
chrono/agendas/migrations/0056_event_cancelled.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-07-29 09:47 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 | ||
7 | ||
8 |
class Migration(migrations.Migration): |
|
9 | ||
10 |
dependencies = [ |
|
11 |
('agendas', '0055_booking_cancel_callback_url'), |
|
12 |
] |
|
13 | ||
14 |
operations = [ |
|
15 |
migrations.AddField( |
|
16 |
model_name='event', |
|
17 |
name='cancelled', |
|
18 |
field=models.BooleanField( |
|
19 |
default=False, help_text="Cancel this event so that it won't be bookable anymore." |
|
20 |
), |
|
21 |
), |
|
22 |
] |
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 |
) |
|
776 | 780 |
meeting_type = models.ForeignKey(MeetingType, null=True, on_delete=models.CASCADE) |
777 | 781 |
desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE) |
778 | 782 |
resources = models.ManyToManyField('Resource') |
... | ... | |
932 | 936 | |
933 | 937 |
return new_event |
934 | 938 | |
939 |
def cancel(self, trigger_callback=True): |
|
940 |
with transaction.atomic(): |
|
941 |
for booking in self.booking_set.filter(cancellation_datetime__isnull=True).all(): |
|
942 |
booking.cancel(trigger_callback) |
|
943 |
self.cancelled = True |
|
944 |
self.save() |
|
945 | ||
935 | 946 | |
936 | 947 |
class Booking(models.Model): |
937 | 948 |
event = models.ForeignKey(Event, on_delete=models.CASCADE) |
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 |
exclude = ['full', 'meeting_type', 'desk', 'slug', 'resources', 'cancelled']
|
|
119 | 119 | |
120 | 120 | |
121 | 121 |
class EventForm(forms.ModelForm): |
... | ... | |
126 | 126 |
'start_datetime': DateTimeWidget(), |
127 | 127 |
'publication_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), |
128 | 128 |
} |
129 |
exclude = ['full', 'meeting_type', 'desk', 'resources'] |
|
129 |
exclude = ['full', 'meeting_type', 'desk', 'resources', 'cancelled']
|
|
130 | 130 | |
131 | 131 | |
132 | 132 |
class AgendaResourceForm(forms.Form): |
... | ... | |
473 | 473 |
class Meta: |
474 | 474 |
model = Booking |
475 | 475 |
fields = [] |
476 | ||
477 | ||
478 |
class EventCancelForm(forms.ModelForm): |
|
479 |
disable_trigger = forms.BooleanField( |
|
480 |
label=_('Do not send cancel triggers to forms'), |
|
481 |
initial=False, |
|
482 |
required=False, |
|
483 |
widget=forms.HiddenInput, |
|
484 |
) |
|
485 | ||
486 |
def show_trigger_checkbox(self): |
|
487 |
self.fields['disable_trigger'].widget = forms.CheckboxInput() |
|
488 | ||
489 |
class Meta: |
|
490 |
model = Event |
|
491 |
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 | ||
28 | 32 |
li span.duration { |
29 | 33 |
font-size: 80%; |
30 | 34 |
} |
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.cancelled %}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.cancelled %} |
|
13 |
<span class="cancelled tag">{% trans "Cancelled" %}</span> |
|
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.cancelled %} |
|
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 |
{% blocktrans %}The {{ bookings_count }} related bookings will also be cancelled.{% endblocktrans %} |
|
25 |
{% endif %} |
|
26 |
</p> |
|
27 |
<input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}"> |
|
28 |
{{ form.as_p }} |
|
29 |
<div class="buttons"> |
|
30 |
<button class="delete-button">{% trans "Proceed with cancellation" %}</button> |
|
31 |
<a class="cancel" href="{{ view.get_success_url }}">{% trans 'Abort' %}</a> |
|
32 |
</div> |
|
33 |
{% endif %} |
|
34 |
</form> |
|
35 |
{% endblock %} |
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 |
), |
|
91 | 96 |
url( |
92 | 97 |
r'^agendas/(?P<pk>\d+)/add-resource/$', |
93 | 98 |
views.agenda_add_resource, |
chrono/manager/views.py | ||
---|---|---|
88 | 88 |
CategoryAddForm, |
89 | 89 |
CategoryEditForm, |
90 | 90 |
BookingCancelForm, |
91 |
EventCancelForm, |
|
91 | 92 |
) |
92 | 93 |
from .utils import import_site |
93 | 94 | |
... | ... | |
1827 | 1828 |
time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view() |
1828 | 1829 | |
1829 | 1830 | |
1830 |
class BookingCancelView(ViewableAgendaMixin, UpdateView): |
|
1831 |
template_name = 'chrono/manager_confirm_booking_cancellation.html' |
|
1832 |
model = Booking |
|
1833 |
pk_url_kwarg = 'booking_pk' |
|
1834 |
form_class = BookingCancelForm |
|
1835 | ||
1836 |
def dispatch(self, request, *args, **kwargs): |
|
1837 |
self.booking = self.get_object() |
|
1838 |
return super().dispatch(request, *args, **kwargs) |
|
1831 |
class CancelView(ViewableAgendaMixin, UpdateView): |
|
1832 |
error_msg = _('There has been an error sending cancellation notification to form.') |
|
1839 | 1833 | |
1840 | 1834 |
def form_valid(self, form): |
1841 | 1835 |
trigger_callback = not form.cleaned_data['disable_trigger'] |
1842 | 1836 |
try: |
1843 |
self.booking.cancel(trigger_callback)
|
|
1837 |
self.object.cancel(trigger_callback)
|
|
1844 | 1838 |
except requests.RequestException as e: |
1845 |
form.add_error(None, _('There has been an error sending cancellation notification to form.'))
|
|
1839 |
form.add_error(None, self.error_msg)
|
|
1846 | 1840 |
form.add_error(None, _('Check this box if you are sure you want to proceed anyway.')) |
1847 | 1841 |
form.show_trigger_checkbox() |
1848 | 1842 |
return self.form_invalid(form) |
... | ... | |
1852 | 1846 |
next_url = self.request.POST.get('next') |
1853 | 1847 |
if next_url: |
1854 | 1848 |
return next_url |
1855 |
event = self.booking.event |
|
1856 |
day = event.start_datetime |
|
1849 |
day = self.event.start_datetime |
|
1857 | 1850 |
return reverse( |
1858 | 1851 |
'chrono-manager-agenda-month-view', |
1859 |
kwargs={'pk': event.agenda.pk, 'year': day.year, 'month': day.month},
|
|
1852 |
kwargs={'pk': self.agenda.pk, 'year': day.year, 'month': day.month},
|
|
1860 | 1853 |
) |
1861 | 1854 | |
1862 | 1855 | |
1856 |
class BookingCancelView(CancelView): |
|
1857 |
template_name = 'chrono/manager_confirm_booking_cancellation.html' |
|
1858 |
model = Booking |
|
1859 |
pk_url_kwarg = 'booking_pk' |
|
1860 |
form_class = BookingCancelForm |
|
1861 | ||
1862 |
def dispatch(self, request, *args, **kwargs): |
|
1863 |
self.event = self.get_object().event |
|
1864 |
return super().dispatch(request, *args, **kwargs) |
|
1865 | ||
1866 | ||
1863 | 1867 |
booking_cancel = BookingCancelView.as_view() |
1864 | 1868 | |
1865 | 1869 | |
1870 |
class EventCancelView(CancelView): |
|
1871 |
template_name = 'chrono/manager_confirm_event_cancellation.html' |
|
1872 |
model = Event |
|
1873 |
pk_url_kwarg = 'event_pk' |
|
1874 |
form_class = EventCancelForm |
|
1875 |
error_msg = _('An error occured while sending cancellation notifications to forms.') |
|
1876 | ||
1877 |
def dispatch(self, request, *args, **kwargs): |
|
1878 |
self.event = self.get_object() |
|
1879 |
return super().dispatch(request, *args, **kwargs) |
|
1880 | ||
1881 |
def get_context_data(self, **kwargs): |
|
1882 |
context = super().get_context_data(**kwargs) |
|
1883 |
context['bookings_count'] = self.event.booking_set.filter(cancellation_datetime__isnull=True).count() |
|
1884 |
context['cancellation_forbidden'] = ( |
|
1885 |
self.event.booking_set.filter(cancel_callback_url='').exclude(backoffice_url='').exists() |
|
1886 |
) |
|
1887 |
return context |
|
1888 | ||
1889 | ||
1890 |
event_cancel = EventCancelView.as_view() |
|
1891 | ||
1892 | ||
1866 | 1893 |
def menu_json(request): |
1867 | 1894 |
label = _('Agendas') |
1868 | 1895 |
json_str = json.dumps( |
tests/test_manager.py | ||
---|---|---|
3706 | 3706 | |
3707 | 3707 |
resp = resp.follow() |
3708 | 3708 |
assert 'Bookings (0/10)' in resp.text |
3709 | ||
3710 | ||
3711 |
def test_event_cancellation(app, admin_user): |
|
3712 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
3713 |
event = Event.objects.create( |
|
3714 |
label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda |
|
3715 |
) |
|
3716 |
day = event.start_datetime |
|
3717 | ||
3718 |
login(app) |
|
3719 |
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) |
|
3720 |
assert '0/10 bookings' in resp.text |
|
3721 | ||
3722 |
resp = resp.click('Cancel') |
|
3723 |
assert not 'related bookings' in resp.text |
|
3724 | ||
3725 |
booking = Booking.objects.create(event=event) |
|
3726 |
booking2 = Booking.objects.create(event=event) |
|
3727 | ||
3728 |
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) |
|
3729 |
assert '2/10 bookings' in resp.text |
|
3730 | ||
3731 |
resp = resp.click('Cancel') |
|
3732 |
assert '2 related bookings will also be cancelled.' in resp.text |
|
3733 | ||
3734 |
resp = resp.form.submit().follow() |
|
3735 |
assert 'Cancelled' in resp.text |
|
3736 |
assert '0/10 bookings' in resp.text |
|
3737 |
assert Booking.objects.filter(event=event, cancellation_datetime__isnull=False).count() == 2 |
|
3738 | ||
3739 | ||
3740 |
def test_event_cancellation_callback_error(app, admin_user): |
|
3741 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
3742 |
event = Event.objects.create( |
|
3743 |
label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda |
|
3744 |
) |
|
3745 |
booking = Booking.objects.create(event=event) |
|
3746 |
booking2 = Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/') |
|
3747 |
day = event.start_datetime |
|
3748 | ||
3749 |
def mocked_requests_connection_error(*args, **kwargs): |
|
3750 |
raise requests.exceptions.ConnectionError('unreachable') |
|
3751 | ||
3752 |
login(app) |
|
3753 |
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) |
|
3754 |
resp = resp.click('Cancel') |
|
3755 |
assert resp.form['disable_trigger'].attrs['type'] == 'hidden' |
|
3756 | ||
3757 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
3758 |
mock_response = mock.Mock(status_code=200) |
|
3759 |
mock_send.return_value = mock_response |
|
3760 |
mock_send.side_effect = mocked_requests_connection_error |
|
3761 |
resp = resp.form.submit() |
|
3762 | ||
3763 |
assert 'error' in resp.text |
|
3764 |
assert not Booking.objects.filter(cancellation_datetime__isnull=False).exists() |
|
3765 | ||
3766 |
resp.form['disable_trigger'] = True |
|
3767 |
resp = resp.form.submit() |
|
3768 |
assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 2 |
|
3769 | ||
3770 | ||
3771 |
def test_event_cancellation_forbidden(app, admin_user): |
|
3772 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
3773 |
event = Event.objects.create( |
|
3774 |
label='xyz', start_datetime=now() + datetime.timedelta(days=1), places=10, agenda=agenda |
|
3775 |
) |
|
3776 |
booking = Booking.objects.create(event=event) |
|
3777 |
booking2 = Booking.objects.create(event=event, backoffice_url='http://example.org/backoffice/xx/') |
|
3778 |
day = event.start_datetime |
|
3779 | ||
3780 |
login(app) |
|
3781 |
resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, day.year, day.month)) |
|
3782 |
resp = resp.click('Cancel') |
|
3783 |
assert 'event has bookings with no callback url configured' in resp.text |
|
3784 |
assert 'Proceed with cancellation' not in resp.text |
|
3709 |
- |