Allow to cancel all orders in an event (#1596)

* Allow to cancel all orders in an event

* Add tests

* Actually add tests
This commit is contained in:
Raphael Michel
2020-03-03 16:55:05 +01:00
committed by GitHub
parent 07318be4c9
commit 62a86c9b4a
17 changed files with 929 additions and 66 deletions

View File

@@ -267,6 +267,10 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name
),
SimpleFunctionalMailTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
lambda event_or_subevent: event_or_subevent.name
),
SimpleFunctionalMailTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
),
@@ -279,6 +283,11 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalMailTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
),
SimpleFunctionalMailTextPlaceholder(
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
event.currency),

View File

@@ -448,16 +448,17 @@ class Order(LockModel, LoggedModel):
@cached_property
def user_cancel_fee(self):
fee = Decimal('0.00')
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
return round_decimal(fee, self.event.currency)
@property

View File

@@ -0,0 +1,190 @@
import logging
from decimal import Decimal
from django.db import transaction
from django.db.models import (
Count, Exists, IntegerField, OuterRef, Subquery, Sum,
)
from i18nfield.strings import LazyI18nString
from pretix.base.decimal import round_decimal
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import (
Event, InvoiceAddress, Order, OrderFee, OrderPosition, SubEvent, User,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
)
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
logger = logging.getLogger(__name__)
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
refund_amount: Decimal, user: User, positions: list):
with language(order.locale):
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress()
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
try:
order.send_mail(
subject, message, email_context,
'pretix.event.order.email.event_canceled',
user,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in positions:
if subevent and p.subevent_id != subevent.id:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
email_context = get_email_context(event_or_subevent=subevent or order.event,
event=order.event,
refund_amount=refund_amount,
position_or_address=p,
order=order, position=p)
try:
order.send_mail(
subject, message, email_context,
'pretix.event.order.email.event_canceled',
position=p,
user=user
)
except SendMailException:
logger.exception('Order canceled email could not be sent to attendee')
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
keep_fee_percentage: str, keep_fees: bool, send: bool, send_subject: dict,
send_message: dict, user: int):
send_subject = LazyI18nString(send_subject)
send_message = LazyI18nString(send_message)
if user:
user = User.objects.get(pk=user)
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
pcnt__gt=0
).all()
if subevent:
subevent = event.subevents.get(pk=subevent)
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
subevent=subevent
)
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
subevent=subevent
)
orders_to_change = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=True
)
orders_to_cancel = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=False
)
subevent.active = False
subevent.save(update_fields=['active'])
subevent.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
else:
orders_to_change = event.orders.none()
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
for o in orders_to_cancel.only('id', 'total'):
try:
refund_amount = Decimal('0.00')
fee = Decimal('0.00')
if keep_fees:
fee += o.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if keep_fee_percentage:
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee)
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee)
if auto_refund:
_try_auto_refund(o.pk)
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
except LockTimeoutException:
logger.exception("Could not cancel order")
failed += 1
except OrderError:
logger.exception("Could not cancel order")
failed += 1
for o in orders_to_change.values_list('id', flat=True):
with transaction.atomic():
o = event.orders.select_for_update().get(pk=o)
refund_amount = Decimal('0.00')
total = Decimal('0.00')
positions = []
ocm = OrderChangeManager(o, user=user, notify=False)
for p in o.positions.all():
if p.subevent == subevent:
total += p.price
ocm.cancel(p)
positions.append(p)
fee = Decimal('0.00')
if keep_fee_fixed:
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)
if fee:
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=fee,
order=o,
tax_rule=o.event.settings.tax_rate_default,
)
f._calculate_tax()
ocm.add_fee(f)
ocm.commit()
if auto_refund:
_try_auto_refund(o.pk)
if send:
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
return failed

View File

@@ -279,7 +279,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
"""
with transaction.atomic():
if isinstance(order, int):
order = Order.objects.get(pk=order)
order = Order.objects.select_for_update().get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
if isinstance(api_token, int):
@@ -1075,6 +1075,7 @@ class OrderChangeManager:
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
@@ -1188,6 +1189,11 @@ class OrderChangeManager:
self._operations.append(self.CancelFeeOperation(fee))
self._invoice_dirty = True
def add_fee(self, fee: OrderFee):
self._totaldiff += fee.value
self._invoice_dirty = True
self._operations.append(self.AddFeeOperation(fee))
def change_fee(self, fee: OrderFee, value: Decimal):
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
self._totaldiff += value.gross - fee.value
@@ -1448,6 +1454,13 @@ class OrderChangeManager:
invoice_address=self._invoice_address
).gross
)
elif isinstance(op, self.AddFeeOperation):
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
'fee': op.fee.pk,
})
op.fee.order = self.order
op.fee._calculate_tax()
op.fee.save()
elif isinstance(op, self.FeeValueOperation):
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
'fee': op.fee.pk,
@@ -1800,6 +1813,61 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
raise OrderError(str(error_messages['busy']))
def _try_auto_refund(order):
notify_admin = False
error = False
if isinstance(order, int):
order = Order.objects.get(pk=order)
refund_amount = order.pending_sum * -1
if refund_amount <= Decimal('0.00'):
return
proposals = order.propose_auto_refunds(refund_amount)
can_auto_refund = sum(proposals.values()) == refund_amount
if can_auto_refund:
for p, value in proposals.items():
with transaction.atomic():
r = order.refunds.create(
payment=p,
source=OrderRefund.REFUND_SOURCE_BUYER,
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
provider=p.provider
)
order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
})
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
with transaction.atomic():
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
order.log_action('pretix.event.order.refund.failed', {
'local_id': r.local_id,
'provider': r.provider,
'error': str(e)
})
error = True
notify_admin = True
else:
if r.state != OrderRefund.REFUND_STATE_DONE:
notify_admin = True
elif refund_amount != Decimal('0.00'):
notify_admin = True
if notify_admin:
order.log_action('pretix.event.order.refund.requested')
if error:
raise OrderError(
_(
'There was an error while trying to send the money back to you. Please contact the event organizer '
'for further information.')
)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
@scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
@@ -1809,52 +1877,7 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee)
if try_auto_refund:
notify_admin = False
error = False
order = Order.objects.get(pk=order)
refund_amount = order.pending_sum * -1
proposals = order.propose_auto_refunds(refund_amount)
can_auto_refund = sum(proposals.values()) == refund_amount
if can_auto_refund:
for p, value in proposals.items():
with transaction.atomic():
r = order.refunds.create(
payment=p,
source=OrderRefund.REFUND_SOURCE_BUYER,
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
provider=p.provider
)
order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
})
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
with transaction.atomic():
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
order.log_action('pretix.event.order.refund.failed', {
'local_id': r.local_id,
'provider': r.provider,
'error': str(e)
})
error = True
notify_admin = True
else:
if r.state != OrderRefund.REFUND_STATE_DONE:
notify_admin = True
elif refund_amount != Decimal('0.00'):
notify_admin = True
if notify_admin:
order.log_action('pretix.event.order.refund.requested')
if error:
raise OrderError(
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
)
_try_auto_refund(order)
return ret
except LockTimeoutException:
self.retry()

View File

@@ -7,7 +7,11 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django.utils.translation import (
gettext_noop, pgettext_lazy, ugettext_lazy as _,
)
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator
@@ -514,3 +518,99 @@ class OrderRefundForm(forms.Form):
if data.get('mode') == 'partial' and not data.get('partial_amount'):
raise ValidationError(_('You need to specify an amount for a partial refund.'))
return data
class EventCancelForm(forms.Form):
subevent = forms.ModelChoiceField(
SubEvent.objects.none(),
label=pgettext_lazy('subevent', 'Date'),
required=True,
empty_label=None
)
auto_refund = forms.BooleanField(
label=_('Automatically refund money if possible'),
initial=True,
required=False
)
keep_fee_fixed = forms.DecimalField(
label=_("Keep a fixed cancellation fee"),
max_digits=10, decimal_places=2,
required=False
)
keep_fee_percentage = forms.DecimalField(
label=_("Keep a percentual cancellation fee"),
max_digits=10, decimal_places=2,
required=False
)
keep_fees = forms.BooleanField(
label=_("Keep payment, shipping and service fees"),
required=False,
)
send = forms.BooleanField(
label=_("Send information via email"),
required=False
)
send_subject = forms.CharField()
send_message = forms.CharField()
def _set_field_placeholders(self, fn, base_parameters):
phs = [
'{%s}' % p
for p in sorted(get_available_placeholders(self.event, base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(phs)
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['send_subject'] = I18nFormField(
label=_("Subject"),
required=True,
initial=_('Canceled: {event}'),
widget=I18nTextInput,
locales=self.event.settings.get('locales'),
)
self.fields['send_message'] = I18nFormField(
label=_('Message'),
widget=I18nTextarea,
required=True,
locales=self.event.settings.get('locales'),
initial=LazyI18nString.from_gettext(gettext_noop(
'Hello,\n\n'
'with this email, we regret to inform you that {event} has been canceled.\n\n'
'We will refund you {refund_amount} to your original payment method.\n\n'
'You can view the current state of your order here:\n\n{url}\n\nBest regards,\n\n'
'Your {event} team'
))
)
self._set_field_placeholders('send_subject', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'])
self._set_field_placeholders('send_message', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'])
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'Date')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
self.fields['subevent'].required = True
else:
del self.fields['subevent']
change_decimal_field(self.fields['keep_fee_fixed'], self.event.currency)

View File

@@ -65,6 +65,8 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.addfee':
return text + ' ' + str(_('A fee has been added'))
elif logentry.action_type == 'pretix.event.order.changed.feevalue':
return text + ' ' + _('A fee was changed from {old_price} to {new_price}.').format(
old_price=money_filter(Decimal(data['old_price']), event.currency),
@@ -213,6 +215,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.email.expire_warning_sent': _('An email has been sent with a warning that the order is about '
'to expire.'),
'pretix.event.order.email.order_canceled': _('An email has been sent to notify the user that the order has been canceled.'),
'pretix.event.order.email.event_canceled': _('An email has been sent to notify the user that the event has '
'been canceled.'),
'pretix.event.order.email.order_changed': _('An email has been sent to notify the user that the order has been changed.'),
'pretix.event.order.email.order_free': _('An email has been sent to notify the user that the order has been received.'),
'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'),

View File

@@ -0,0 +1,95 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block content %}
<h1>{% trans "Cancel or delete event" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Go offline" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can take your event offline. Nobody except your team will be able to see or access it any more.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<form action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
<button type="submit" class="btn btn-primary btn-lg btn-block">
<span class="fa fa-power-off"></span>
{% trans "Go offline" %}
</button>
</form>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Cancel event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
If you need to call of your event you want to cancel and refund all tickets, you can do so through
this option.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-block btn-lg">
<span class="fa fa-ban"></span>
{% trans "Cancel event" %}
</a>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Delete personal data" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can remove personal data such as names and email addresses from your event and only retain the
finanical information such as the number and type of ticekts sold.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<a href="
{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg btn-block">
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
</a>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Delete event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can delete your event completely only as long as it does not contain any undeletable data, such as
orders not performed in test mode.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-block btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
<span class="fa fa-trash"></span>
{% trans "Delete event" %}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -112,4 +112,11 @@
<div class="clear"></div>
</div>
</div>
<div class="text-right">
<a href="{% url "control:event.dangerzone" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-lg">
<span class="fa fa-trash"></span>
{% trans "Cancel or delete event" %}
</a>
</div>
{% endblock %}

View File

@@ -209,15 +209,10 @@
{% trans "Save" %}
</button>
<div class="pull-left">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn {% if request.event.allow_delete %}{% endif %} btn-danger btn-lg">
<span class="fa fa-trash"></span>
{% trans "Delete event" %}
</a>
<a href="{% url "control:event.shredder.start" organizer=request.organizer.slug event=request.event.slug %}"
<a href="{% url "control:event.dangerzone" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg">
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
<span class="fa fa-trash"></span>
{% trans "Cancel or delete event" %}
</a>
<a href="{% url "control:events.add" %}?clone={{ request.event.pk }}"
class="btn btn-default btn-lg">

View File

@@ -0,0 +1,52 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventsignal %}
{% load bootstrap3 %}
{% block title %}{% trans "Cancel event" %}{% endblock %}
{% block content %}
<h1>{% trans "Cancel event" %}</h1>
<div class="alert alert-warning">
{% blocktrans trimmed %}
You can use this page to cancel and refund all orders at once in case you need to call of your event.
This will also disable all products so no new orders can be created. Make sure that you check afterwards
for any overpaid orders or pending refunds that you need to take care of manually.
{% endblocktrans %}
<br><br>
{% blocktrans trimmed %}
After starting this operation, depending on the size of your event, it might take a few minutes or longer
until all orders are processed.
{% endblocktrans %}
<br><br>
<strong>
{% trans "All actions performed on this page are irreversible. If in doubt, please contact support before using it." %}
</strong>
</div>
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-download data-asynctask-long>
{% csrf_token %}
{% bootstrap_form_errors form %}
{% if request.event.has_subevents %}
<fieldset>
<legend>{% trans "Select date" context "subevents" %}</legend>
{% bootstrap_field form.subevent layout="control" %}
</fieldset>
{% endif %}
<fieldset>
<legend>{% trans "Refund options" %}</legend>
{% bootstrap_field form.auto_refund layout="control" %}
{% bootstrap_field form.keep_fee_fixed layout="control" %}
{% bootstrap_field form.keep_fee_percentage layout="control" %}
{% bootstrap_field form.keep_fees layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Send out emails" %}</legend>
{% bootstrap_field form.send layout="control" %}
{% bootstrap_field form.send_subject layout="horizontal" %}
{% bootstrap_field form.send_message layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-danger btn-save">
{% trans "Cancel all orders" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -265,6 +265,8 @@ urlpatterns = [
url(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'),
url(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'),
url(r'^shredder/$', shredder.StartShredView.as_view(), name='event.shredder.start'),
url(r'^shredder/export$', shredder.ShredExportView.as_view(), name='event.shredder.export'),
url(r'^shredder/download/(?P<file>[^/]+)/$', shredder.ShredDownloadView.as_view(), name='event.shredder.download'),

View File

@@ -530,6 +530,11 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
return resp
class DangerZone(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/dangerzone.html'
class DisplaySettings(View):
def get(self, request, *wargs, **kwargs):
return redirect(reverse('control:event.settings', kwargs={

View File

@@ -46,6 +46,7 @@ from pretix.base.models.orders import (
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.payment import PaymentException
from pretix.base.services import tickets
from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.export import export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
@@ -71,10 +72,11 @@ from pretix.control.forms.filter import (
EventOrderFilterForm, OverviewFilterForm, RefundFilterForm,
)
from pretix.control.forms.orders import (
CancelForm, CommentForm, ConfirmPaymentForm, ExporterForm, ExtendForm,
MarkPaidForm, OrderContactForm, OrderFeeChangeForm, OrderLocaleForm,
OrderMailForm, OrderPositionAddForm, OrderPositionAddFormset,
OrderPositionChangeForm, OrderRefundForm, OtherOperationsForm,
CancelForm, CommentForm, ConfirmPaymentForm, EventCancelForm, ExporterForm,
ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeChangeForm,
OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
OrderPositionAddFormset, OrderPositionChangeForm, OrderRefundForm,
OtherOperationsForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import PaginationMixin
@@ -1883,3 +1885,63 @@ class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView):
def filter_form(self):
return RefundFilterForm(data=self.request.GET, event=self.request.event,
initial={'status': 'open'})
class EventCancel(EventPermissionRequiredMixin, AsyncAction, FormView):
template_name = 'pretixcontrol/orders/cancel.html'
permission = 'can_change_orders'
form_class = EventCancelForm
task = cancel_event
known_errortypes = ['OrderError']
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['event'] = self.request.event
return k
def form_valid(self, form):
return self.do(
self.request.event.pk,
subevent=form.cleaned_data['subevent'].pk if form.cleaned_data.get('subevent') else None,
auto_refund=form.cleaned_data.get('auto_refund'),
keep_fee_fixed=form.cleaned_data.get('keep_fee_fixed'),
keep_fee_percentage=form.cleaned_data.get('keep_fee_percentage'),
keep_fees=form.cleaned_data.get('keep_fees'),
send=form.cleaned_data.get('send'),
send_subject=form.cleaned_data.get('send_subject').data,
send_message=form.cleaned_data.get('send_message').data,
user=self.request.user.pk,
)
def get_success_message(self, value):
if value == 0:
return _('All orders have been canceled.')
else:
return _('The orders have been canceled. An error occured with {count} orders, please '
'check all uncanceled orders.').format(count=value)
def get_success_url(self, value):
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)

View File

@@ -0,0 +1,312 @@
from datetime import timedelta
from decimal import Decimal
from django.core import mail as djmail
from django.test import TestCase
from django.utils.timezone import now
from django_scopes import scope
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.invoices import generate_invoice
from pretix.testutils.scope import classscope
class EventCancelTests(TestCase):
def setUp(self):
super().setUp()
self.o = Organizer.objects.create(name='Dummy', slug='dummy')
with scope(organizer=self.o):
self.event = Event.objects.create(organizer=self.o, name='Dummy', slug='dummy', date_from=now(),
plugins='tests.testdummy')
self.order = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('46.00'),
)
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
self.op1 = OrderPosition.objects.create(
order=self.order, item=self.ticket, variation=None,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
self.op2 = OrderPosition.objects.create(
order=self.order, item=self.ticket, variation=None,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2
)
generate_invoice(self.order)
djmail.outbox = []
@classscope(attr='o')
def test_cancel_send_mail(self):
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
assert len(djmail.outbox) == 1
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@classscope(attr='o')
def test_cancel_send_mail_attendees(self):
self.op1.attendee_email = 'foo@example.com'
self.op1.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
assert len(djmail.outbox) == 2
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@classscope(attr='o')
def test_cancel_auto_refund(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
p1 = self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('46.00')
assert r.source == OrderRefund.REFUND_SOURCE_BUYER
assert r.payment == p1
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
assert gc.value == Decimal('46.00')
@classscope(attr='o')
def test_cancel_do_not_refund(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=False, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
assert not self.order.refunds.exists()
@classscope(attr='o')
def test_cancel_refund_paid_with_fees(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
p1 = self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="10.00", keep_fee_percentage="10.00",
keep_fees=True, send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('31.40')
assert r.source == OrderRefund.REFUND_SOURCE_BUYER
assert r.payment == p1
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
assert gc.value == Decimal('31.40')
@classscope(attr='o')
def test_cancel_refund_partially_paid_with_fees(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
self.order.payments.create(
amount=Decimal('12.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.order.status = Order.STATUS_PENDING
self.order.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="10.00", keep_fee_percentage="10.00",
keep_fees=True, send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
assert not self.order.refunds.exists()
self.order.refresh_from_db()
assert self.order.total == Decimal('12.00')
assert self.order.status == Order.STATUS_PAID
assert self.order.positions.count() == 0
@classscope(attr='o')
def test_cancel_keep_fees(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
p1 = self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.op1.price -= Decimal('5.00')
self.op1.save()
self.order.fees.create(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=Decimal('5.00'),
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00",
keep_fees=True, send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('36.90')
assert r.source == OrderRefund.REFUND_SOURCE_BUYER
assert r.payment == p1
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
assert gc.value == Decimal('36.90')
class SubEventCancelTests(TestCase):
def setUp(self):
super().setUp()
self.o = Organizer.objects.create(name='Dummy', slug='dummy')
with scope(organizer=self.o):
self.event = Event.objects.create(organizer=self.o, name='Dummy', slug='dummy', date_from=now(),
plugins='tests.testdummy', has_subevents=True)
self.se1 = self.event.subevents.create(name='One', date_from=now())
self.se2 = self.event.subevents.create(name='Two', date_from=now())
self.order = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('46.00'),
)
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
self.op1 = OrderPosition.objects.create(
order=self.order, item=self.ticket, variation=None, subevent=self.se1,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
self.op2 = OrderPosition.objects.create(
order=self.order, item=self.ticket, variation=None, subevent=self.se2,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2
)
generate_invoice(self.order)
djmail.outbox = []
@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.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
assert len(djmail.outbox) == 2
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
assert self.order.positions.count() == 1
@classscope(attr='o')
def test_cancel_simple_order(self):
self.op2.subevent = self.se1
self.op2.save()
cancel_event(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@classscope(attr='o')
def test_cancel_mixed_order(self):
cancel_event(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00",
keep_fees=True, send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
assert self.order.positions.filter(subevent=self.se2).count() == 1
assert self.order.positions.filter(subevent=self.se1).count() == 0
@classscope(attr='o')
def test_cancel_partially_keep_fees(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
p1 = self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.op1.price -= Decimal('5.00')
self.op1.save()
self.order.fees.create(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=Decimal('5.00'),
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00",
keep_fees=True, send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('16.20')
assert r.source == OrderRefund.REFUND_SOURCE_BUYER
assert r.payment == p1
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
assert gc.value == Decimal('16.20')
assert self.order.positions.filter(subevent=self.se2).count() == 1
assert self.order.positions.filter(subevent=self.se1).count() == 0
f = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_CANCELLATION)
assert f.value == Decimal('1.80')

View File

@@ -1320,7 +1320,7 @@ class OrderTestCase(BaseQuotaTestCase):
self.event.settings.cancel_allow_user_paid_keep_fees = True
self.order = Order.objects.get(pk=self.order.pk)
assert self.order.user_cancel_fee == Decimal('9.30')
assert self.order.user_cancel_fee == Decimal('9.10')
@classscope(attr='o')
def test_paid_order_underpaid(self):

View File

@@ -51,6 +51,8 @@ event_urls = [
"comment/",
"live/",
"delete/",
"dangerzone/",
"cancel/",
"settings/",
"settings/plugins",
"settings/payment",
@@ -222,6 +224,7 @@ def test_wrong_event(perf_patch, client, env, url):
event_permission_urls = [
("can_change_event_settings", "live/", 200),
("can_change_event_settings", "delete/", 200),
("can_change_event_settings", "dangerzone/", 200),
("can_change_event_settings", "settings/", 200),
("can_change_event_settings", "settings/plugins", 200),
("can_change_event_settings", "settings/payment", 200),
@@ -285,6 +288,7 @@ event_permission_urls = [
("can_change_orders", "orders/import/", 200),
("can_change_orders", "orders/import/0ab7b081-92d3-4480-82de-2f8b056fd32f/", 404),
("can_view_orders", "orders/FOO/answer/5/", 404),
("can_change_orders", "cancel/", 200),
("can_change_vouchers", "vouchers/add", 200),
("can_change_orders", "requiredactions/", 200),
("can_change_vouchers", "vouchers/bulk_add", 200),

View File

@@ -109,6 +109,8 @@ def logged_in_client(client, event):
('/control/event/{orga}/{event}/', 200),
('/control/event/{orga}/{event}/live/', 200),
('/control/event/{orga}/{event}/dangerzone/', 200),
('/control/event/{orga}/{event}/cancel/', 200),
('/control/event/{orga}/{event}/settings/', 200),
('/control/event/{orga}/{event}/settings/plugins', 200),
('/control/event/{orga}/{event}/settings/payment', 200),