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

@@ -24,6 +24,7 @@ from decimal import Decimal
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
from django.utils.crypto import get_random_string
from django.utils.translation import gettext
from i18nfield.strings import LazyI18nString
@@ -41,6 +42,7 @@ from pretix.base.services.orders import (
)
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.services.tax import split_fee_for_taxes
from pretix.base.templatetags.money import money_filter
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.format import format_map
@@ -112,7 +114,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
manual_refund: bool=False, send: bool=False, send_subject: dict=None, send_message: dict=None,
send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={},
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None,
subevents_from: str=None, subevents_to: str=None):
subevents_from: str=None, subevents_to: str=None, dry_run=False):
send_subject = LazyI18nString(send_subject)
send_message = LazyI18nString(send_message)
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
@@ -161,32 +163,72 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
has_subevent=True, has_other_subevent=False, has_blocked=False
)
for se in subevents:
se.log_action(
'pretix.subevent.canceled', user=user,
)
se.active = False
se.save(update_fields=['active'])
se.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
if not dry_run:
for se in subevents:
se.log_action(
'pretix.subevent.canceled', user=user,
data={
"auto_refund": auto_refund,
"keep_fee_fixed": keep_fee_fixed,
"keep_fee_per_ticket": keep_fee_per_ticket,
"keep_fee_percentage": keep_fee_percentage,
"keep_fees": keep_fees,
"manual_refund": manual_refund,
"send": send,
"send_subject": send_subject,
"send_message": send_message,
"send_waitinglist": send_waitinglist,
"send_waitinglist_subject": send_waitinglist_subject,
"send_waitinglist_message": send_waitinglist_message,
"refund_as_giftcard": refund_as_giftcard,
"giftcard_expires": str(giftcard_expires),
"giftcard_conditions": giftcard_conditions,
}
)
se.active = False
se.save(update_fields=['active'])
se.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
else:
subevents = None
subevent_ids = set()
orders_to_change = orders_to_cancel.filter(has_blocked=True)
orders_to_cancel = orders_to_cancel.filter(has_blocked=False)
event.log_action(
'pretix.event.canceled', user=user,
)
for i in event.items.filter(active=True):
i.active = False
i.save(update_fields=['active'])
i.log_action(
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
if not dry_run:
event.log_action(
'pretix.event.canceled', user=user,
data={
"auto_refund": auto_refund,
"keep_fee_fixed": keep_fee_fixed,
"keep_fee_per_ticket": keep_fee_per_ticket,
"keep_fee_percentage": keep_fee_percentage,
"keep_fees": keep_fees,
"manual_refund": manual_refund,
"send": send,
"send_subject": send_subject,
"send_message": send_message,
"send_waitinglist": send_waitinglist,
"send_waitinglist_subject": send_waitinglist_subject,
"send_waitinglist_message": send_waitinglist_message,
"refund_as_giftcard": refund_as_giftcard,
"giftcard_expires": str(giftcard_expires),
"giftcard_conditions": giftcard_conditions,
}
)
for i in event.items.filter(active=True):
i.active = False
i.save(update_fields=['active'])
i.log_action(
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
failed = 0
total = orders_to_cancel.count() + orders_to_change.count()
refund_total = Decimal("0.00")
cancel_full_total = orders_to_cancel.count()
cancel_partial_total = orders_to_change.count()
total = cancel_full_total + cancel_partial_total
qs_wl = event.waitinglistentries.filter(voucher__isnull=True).select_related('subevent')
if subevents:
qs_wl = qs_wl.filter(subevent__in=subevents)
@@ -199,6 +241,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
)
for o in orders_to_cancel.only('id', 'total').iterator():
payment_refund_sum = o.payment_refund_sum # cache to avoid multiple computations
try:
fee = Decimal('0.00')
fee_sum = Decimal('0.00')
@@ -217,20 +260,24 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
for p in o.positions.all():
if p.addon_to_id is None:
fee += min(p.price, Decimal(keep_fee_per_ticket))
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
fee = round_decimal(min(fee, payment_refund_sum), event.currency)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
refund_amount = o.payment_refund_sum
if dry_run:
refund_total += max(Decimal("0.00"), min(payment_refund_sum, o.total - fee))
else:
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
refund_amount = payment_refund_sum
refund_amount += refund_total
try:
if auto_refund or manual_refund:
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled'))
finally:
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
try:
if auto_refund or manual_refund:
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled'))
finally:
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:
@@ -247,12 +294,16 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
for o in orders_to_change.values_list('id', flat=True).iterator():
with transaction.atomic():
o = event.orders.select_for_update(of=OF_SELF).get(pk=o)
if dry_run:
o = event.orders.get(pk=o)
else:
o = event.orders.select_for_update(of=OF_SELF).get(pk=o)
total = Decimal('0.00')
fee = Decimal('0.00')
positions = []
ocm = OrderChangeManager(o, user=user, notify=False)
payment_refund_sum = o.payment_refund_sum # cache to avoid multiple computations
for p in o.positions.all():
if (not event.has_subevents or p.subevent_id in subevent_ids) and not p.blocked:
total += p.price
@@ -267,7 +318,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
fee += Decimal(keep_fee_fixed)
if keep_fee_percentage:
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
fee = round_decimal(min(fee, payment_refund_sum), event.currency)
if fee:
tax_rule_zero = TaxRule.zero()
if event.settings.tax_rule_cancellation == "default":
@@ -298,17 +349,21 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
)
ocm.add_fee(f)
ocm.commit()
refund_amount = o.payment_refund_sum - o.total
if dry_run:
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00"))
else:
ocm.commit()
refund_amount = payment_refund_sum - o.total
refund_total += refund_amount
if auto_refund or manual_refund:
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled'))
if auto_refund or manual_refund:
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled'))
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:
@@ -319,7 +374,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
if send_waitinglist:
for wle in qs_wl:
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
if not dry_run:
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:
@@ -327,4 +383,30 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
state='PROGRESS',
meta={'value': round(counter / total * 100 if total else 0, 2)}
)
return failed
confirmation_code = None
if dry_run and user and refund_total > Decimal('100.00'):
confirmation_code = get_random_string(8, allowed_chars="01234567890")
mail(
user.email,
subject=gettext('Bulk-refund confirmation'),
template='pretixbase/email/cancel_confirm.txt',
context={
"event": str(event),
"amount": money_filter(refund_total, event.currency),
"confirmation_code": confirmation_code,
},
locale=user.locale,
)
return {
"dry_run": dry_run,
"id": self.request.id,
"failed": failed,
"refund_total": refund_total,
"cancel_full_total": cancel_full_total,
"cancel_partial_total": cancel_partial_total,
"confirmation_code": confirmation_code,
"args": self.request.args,
"kwargs": self.request.kwargs,
}

View File

@@ -0,0 +1,10 @@
{% load i18n %}
{% trans "You have requested us to cancel an event which includes a larger bulk-refund:" %}
{% trans "Event" %}: {{ event }}
{% trans "Estimated refund amount" %}: **{{ amount }}**
{% trans "Please confirm that you want to proceed by coping the following confirmation code into the cancellation form:" %}
**{{ confirmation_code }}**

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 '

View File

@@ -348,7 +348,7 @@ var form_handlers = function (el) {
dependency.on("change", update);
});
el.find("div[data-display-dependency], textarea[data-display-dependency], input[data-display-dependency], select[data-display-dependency]").each(function () {
el.find("div[data-display-dependency], textarea[data-display-dependency], input[data-display-dependency], select[data-display-dependency], button[data-display-dependency]").each(function () {
var dependent = $(this),
dependency = findDependency($(this).attr("data-display-dependency"), this),
update = function (ev) {
@@ -373,10 +373,11 @@ var form_handlers = function (el) {
enabled = !enabled;
}
var $toggling = dependent;
if (dependent.attr("data-disable-dependent")) {
if (dependent.is("[data-disable-dependent]")) {
$toggling.attr('disabled', !enabled).trigger("change");
}
if (dependent.get(0).tagName.toLowerCase() !== "div") {
const tagName = dependent.get(0).tagName.toLowerCase()
if (tagName !== "div" && tagName !== "button") {
$toggling = dependent.closest('.form-group');
}
if (ev) {

View File

@@ -63,6 +63,15 @@ class EventCancelTests(TestCase):
generate_invoice(self.order)
djmail.outbox = []
def _cancel_with_dryrun(self, *args, expected_refunds, **kwargs):
dry_run = cancel_event(
*args, **kwargs, dry_run=True
)
assert dry_run["refund_total"] == expected_refunds
cancel_event(
*args, **kwargs,
)
@classscope(attr='o')
def test_cancel_send_mail(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
@@ -74,11 +83,11 @@ class EventCancelTests(TestCase):
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert len(djmail.outbox) == 1
self.order.refresh_from_db()
@@ -114,11 +123,11 @@ class EventCancelTests(TestCase):
self.op1.blocked = ["admin"]
self.op1.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("23.00")
)
self.op1.refresh_from_db()
@@ -147,11 +156,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
r = self.order.refunds.get()
@@ -175,11 +184,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=False, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
self.order.refresh_from_db()
@@ -198,11 +207,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("42.00")
)
r = self.order.refunds.get()
@@ -226,11 +235,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("44.00")
)
r = self.order.refunds.get()
@@ -252,11 +261,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("44.00")
)
r = self.order.refunds.get()
@@ -276,11 +285,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="10.00", keep_fee_percentage="10.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("31.40")
)
r = self.order.refunds.get()
@@ -304,11 +313,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PENDING
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="10.00", keep_fee_percentage="10.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("12.00")
)
assert not self.order.refunds.exists()
@@ -335,10 +344,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00", keep_fees=[OrderFee.FEE_TYPE_PAYMENT], keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(", user=None
send=False, send_subject="Event canceled", send_message="Event canceled :-(", user=None,
expected_refunds=Decimal("36.90")
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
@@ -371,11 +381,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00", keep_fees=[OrderFee.FEE_TYPE_PAYMENT], keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("39.40")
)
r = self.order.refunds.get()
assert r.amount == Decimal('39.40')
@@ -400,11 +410,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, manual_refund=True,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert self.order.refunds.count() == 2
@@ -436,11 +446,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, manual_refund=False,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert self.order.refunds.count() == 1
@@ -467,11 +477,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, manual_refund=True,
auto_refund=False, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert self.order.refunds.count() == 1
@@ -511,17 +521,26 @@ class SubEventCancelTests(TestCase):
generate_invoice(self.order)
djmail.outbox = []
def _cancel_with_dryrun(self, *args, expected_refunds, **kwargs):
dry_run = cancel_event(
*args, **kwargs, dry_run=True
)
assert dry_run["refund_total"] == expected_refunds
cancel_event(
*args, **kwargs,
)
@classscope(attr='o')
def test_cancel_partially_send_mail_attendees(self):
self.op1.attendee_email = 'foo@example.com'
self.op1.save()
self.op2.attendee_email = 'foo@example.org'
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
assert len(djmail.outbox) == 2
self.order.refresh_from_db()
@@ -532,19 +551,19 @@ class SubEventCancelTests(TestCase):
def test_cancel_subevent_range(self):
self.op2.subevent = self.se1
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from - timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from + timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@@ -553,11 +572,11 @@ class SubEventCancelTests(TestCase):
def test_cancel_simple_order(self):
self.op2.subevent = self.se1
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@@ -567,11 +586,11 @@ class SubEventCancelTests(TestCase):
self.op2.subevent = self.se1
self.op2.blocked = ["admin"]
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
@@ -582,11 +601,11 @@ class SubEventCancelTests(TestCase):
@classscope(attr='o')
def test_cancel_all_subevents(self):
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@@ -602,11 +621,12 @@ class SubEventCancelTests(TestCase):
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.order.refresh_from_db()
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("23.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PAID
@@ -614,20 +634,20 @@ class SubEventCancelTests(TestCase):
@classscope(attr='o')
def test_cancel_mixed_order_range(self):
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from - timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
assert self.order.positions.count() == 2
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from + timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
@@ -651,11 +671,11 @@ class SubEventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("16.20")
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
@@ -682,11 +702,11 @@ class SubEventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("21.00")
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE