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

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