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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }}**
|
||||
@@ -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 '
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user