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:
Raphael Michel
2025-10-29 08:53:48 +01:00
committed by GitHub
parent e386ed4352
commit 1e0ede529c
9 changed files with 422 additions and 103 deletions

View File

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

View File

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

View File

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

View File

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

View File

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