forked from CGM_Public/pretix_original
Event cancellation: Add safety and security checks (#5565)
* Event cancellation: Add safety and security checks When cancelling an event, a large sum of money might be refunded instantly. This PR adds safety features around this by - doing a dry-run first that shows a preview of the expected refund sum - sending a confirmation mode via email for any automatic refunds of more than 100 currency units - keeping a more detailed log of the settings this was executed with * Update src/pretix/control/views/orders.py Co-authored-by: luelista <weller@rami.io> --------- Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
@@ -1030,3 +1030,27 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
|
||||
if self.event.has_subevents and not d['subevent'] and not d['all_subevents'] and not d.get('subevents_from'):
|
||||
raise ValidationError(_('Please confirm that you want to cancel ALL dates in this event series.'))
|
||||
return d
|
||||
|
||||
|
||||
class EventCancelConfirmForm(forms.Form):
|
||||
confirm = forms.BooleanField(
|
||||
label=_("I understand that this is not reversible and want to continue"),
|
||||
required=True,
|
||||
)
|
||||
confirmation_code = forms.CharField(
|
||||
label=_("Confirmation code"),
|
||||
help_text=_("We have just emailed you a confirmation code to enter to confirm this action"),
|
||||
required=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.code = kwargs.pop("confirmation_code")
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.code:
|
||||
del self.fields["confirmation_code"]
|
||||
|
||||
def clean_confirmation_code(self):
|
||||
val = self.cleaned_data['confirmation_code']
|
||||
if val != self.code:
|
||||
raise ValidationError(_('The confirmation code is incorrect.'))
|
||||
return val
|
||||
|
||||
@@ -79,9 +79,15 @@
|
||||
{% bootstrap_field form.send_waitinglist_message layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Cancel all orders" %}
|
||||
</button>
|
||||
{% if dry_run_supported %}
|
||||
<button type="submit" class="btn btn-default btn-save">
|
||||
{% trans "Preview refund amount" %}
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Cancel all orders" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventsignal %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}{% trans "Cancel event" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Cancel event" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-download data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you proceed, the system will do the following:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
{% blocktrans trimmed count count=dryrun_result.cancel_full_total %}
|
||||
{{ count }} order will be canceled fully
|
||||
{% plural %}
|
||||
{{ count }} orders will be canceled fully
|
||||
{% endblocktrans %}
|
||||
</li>
|
||||
<li>
|
||||
{% blocktrans trimmed count count=dryrun_result.cancel_partial_total %}
|
||||
{{ count }} order will be canceled partially
|
||||
{% plural %}
|
||||
{{ count }} orders will be canceled partially
|
||||
{% endblocktrans %}
|
||||
</li>
|
||||
<li>
|
||||
{% if dryrun_result.kwargs.auto_refund and dryrun_result.refund_total %}
|
||||
<strong>
|
||||
{% endif %}
|
||||
{% blocktrans trimmed with amount=dryrun_result.refund_total|money:request.event.currency %}
|
||||
{{ amount }} are eligible for refunds.
|
||||
{% endblocktrans %}
|
||||
{% if dryrun_result.kwargs.auto_refund %}
|
||||
{% trans "The system will attempt to refund the money automatically if supported by the payment method." %}
|
||||
{% elif dryrun_result.kwargs.manual_refund %}
|
||||
{% trans "The system will create manual refunds that you need to execute." %}
|
||||
{% else %}
|
||||
{% trans "Refunds will not happen automatically." %}
|
||||
{% endif %}
|
||||
{% if dryrun_result.kwargs.auto_refund %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% if dryrun_result.kwargs.send %}
|
||||
<li>
|
||||
{% trans "Inform all customers via email." %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if dryrun_result.kwargs.send_waitinglist %}
|
||||
<li>
|
||||
{% trans "Inform all waiting list contacts via email." %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
These numbers are estimates and may change if the data in your event recently changed.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% if form.confirmation_code %}
|
||||
{% bootstrap_field form.confirm layout="control" %}
|
||||
{% bootstrap_field form.confirmation_code layout="control" %}
|
||||
{% else %}
|
||||
{% bootstrap_field form.confirm layout="inline" form_group_class="" %}
|
||||
{% endif %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% if dryrun_result.kwargs.auto_refund and dryrun_result.refund_total %}
|
||||
{% blocktrans trimmed with amount=dryrun_result.refund_total|money:request.event.currency %}
|
||||
Proceed and refund approx. {{ amount }}
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Proceed and cancel orders" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -460,6 +460,7 @@ urlpatterns = [
|
||||
re_path(r'^orders/search$', orders.OrderSearch.as_view(), name='event.orders.search'),
|
||||
re_path(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'),
|
||||
re_path(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'),
|
||||
re_path(r'^cancel/(?P<task>[^/]+)/$', orders.EventCancelConfirm.as_view(), name='event.cancel.confirm'),
|
||||
re_path(r'^shredder/$', shredder.StartShredView.as_view(), name='event.shredder.start'),
|
||||
re_path(r'^shredder/export$', shredder.ShredExportView.as_view(), name='event.shredder.export'),
|
||||
re_path(r'^shredder/download/(?P<file>[^/]+)/$', shredder.ShredDownloadView.as_view(), name='event.shredder.download'),
|
||||
|
||||
@@ -42,6 +42,7 @@ from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal, DecimalException
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -122,8 +123,8 @@ from pretix.control.forms.filter import (
|
||||
RefundFilterForm,
|
||||
)
|
||||
from pretix.control.forms.orders import (
|
||||
CancelForm, CommentForm, DenyForm, EventCancelForm, ExporterForm,
|
||||
ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeAddForm,
|
||||
CancelForm, CommentForm, DenyForm, EventCancelConfirmForm, EventCancelForm,
|
||||
ExporterForm, ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeAddForm,
|
||||
OrderFeeAddFormset, OrderFeeChangeForm, OrderLocaleForm, OrderMailForm,
|
||||
OrderPositionAddForm, OrderPositionAddFormset, OrderPositionChangeForm,
|
||||
OrderPositionMailForm, OrderRefundForm, OtherOperationsForm,
|
||||
@@ -2975,10 +2976,99 @@ class EventCancel(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
send_waitinglist_subject=form.cleaned_data.get('send_waitinglist_subject').data,
|
||||
send_waitinglist_message=form.cleaned_data.get('send_waitinglist_message').data,
|
||||
user=self.request.user.pk,
|
||||
dry_run=settings.HAS_CELERY,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(
|
||||
dry_run_supported=settings.HAS_CELERY,
|
||||
)
|
||||
|
||||
def get_success_message(self, value):
|
||||
if value == 0:
|
||||
if value["dry_run"]:
|
||||
return None
|
||||
elif value["failed"] == 0:
|
||||
return _('All orders have been canceled.')
|
||||
else:
|
||||
return _('The orders have been canceled. An error occurred with {count} orders, please '
|
||||
'check all uncanceled orders.').format(count=value)
|
||||
|
||||
def get_success_url(self, value):
|
||||
if settings.HAS_CELERY:
|
||||
return reverse('control:event.cancel.confirm', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
'task': value["id"],
|
||||
})
|
||||
else:
|
||||
return reverse('control:event.cancel', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
def get_error_url(self):
|
||||
return reverse('control:event.cancel', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
def get_error_message(self, exception):
|
||||
if isinstance(exception, str):
|
||||
return exception
|
||||
return super().get_error_message(exception)
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your input was not valid.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class EventCancelConfirm(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
template_name = 'pretixcontrol/orders/cancel_confirm.html'
|
||||
permission = 'can_change_orders'
|
||||
form_class = EventCancelConfirmForm
|
||||
task = cancel_event
|
||||
known_errortypes = ['OrderError']
|
||||
|
||||
@cached_property
|
||||
def dryrun_result(self):
|
||||
res = AsyncResult(self.kwargs.get("task"))
|
||||
if not res.ready():
|
||||
raise Http404()
|
||||
if not res.successful():
|
||||
raise Http404()
|
||||
data = res.info
|
||||
if not data.get("dry_run"):
|
||||
raise Http404()
|
||||
if data.get("args")[0] != self.request.event.pk:
|
||||
raise Http404()
|
||||
return data
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'async_id' in request.GET and settings.HAS_CELERY:
|
||||
return self.get_result(request)
|
||||
return FormView.get(self, request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
k = super().get_form_kwargs()
|
||||
k['confirmation_code'] = self.dryrun_result["confirmation_code"]
|
||||
return k
|
||||
|
||||
def form_valid(self, form):
|
||||
return self.do(
|
||||
*self.dryrun_result["args"],
|
||||
**{
|
||||
**self.dryrun_result["kwargs"],
|
||||
"dry_run": False,
|
||||
},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(
|
||||
dryrun_result=self.dryrun_result,
|
||||
)
|
||||
|
||||
def get_success_message(self, value):
|
||||
if value["failed"] == 0:
|
||||
return _('All orders have been canceled.')
|
||||
else:
|
||||
return _('The orders have been canceled. An error occurred with {count} orders, please '
|
||||
|
||||
Reference in New Issue
Block a user