diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
index 1f2a9b24ba..29657007d7 100644
--- a/src/pretix/base/email.py
+++ b/src/pretix/base/email.py
@@ -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),
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index 2e6ce1492d..f04202dc00 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -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
diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py
new file mode 100644
index 0000000000..2914f08b01
--- /dev/null
+++ b/src/pretix/base/services/cancelevent.py
@@ -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
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index f4f0e67293..ddd57df73b 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -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()
diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py
index 31da079fa9..8f281a7d7e 100644
--- a/src/pretix/control/forms/orders.py
+++ b/src/pretix/control/forms/orders.py
@@ -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)
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index b12e3d04b2..fef3213b7f 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -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.'),
diff --git a/src/pretix/control/templates/pretixcontrol/event/dangerzone.html b/src/pretix/control/templates/pretixcontrol/event/dangerzone.html
new file mode 100644
index 0000000000..a9ebaae80e
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/event/dangerzone.html
@@ -0,0 +1,95 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block content %}
+
{% trans "Cancel or delete event" %}
+
+
+
+
{% trans "Go offline" %}
+
+
+
+ {% blocktrans trimmed %}
+ You can take your event offline. Nobody except your team will be able to see or access it any more.
+ {% endblocktrans %}
+
+
+
+
+
+
+
+
+
+
{% trans "Cancel event" %}
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+
+
{% trans "Delete personal data" %}
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+
+
{% trans "Delete event" %}
+
+
+
+ {% 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 %}
+
+
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/live.html b/src/pretix/control/templates/pretixcontrol/event/live.html
index b3fbff23cf..ff84b2dcb2 100644
--- a/src/pretix/control/templates/pretixcontrol/event/live.html
+++ b/src/pretix/control/templates/pretixcontrol/event/live.html
@@ -112,4 +112,11 @@
+
{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html
index 2d03ed5dbe..c398b4cacc 100644
--- a/src/pretix/control/templates/pretixcontrol/event/settings.html
+++ b/src/pretix/control/templates/pretixcontrol/event/settings.html
@@ -209,15 +209,10 @@
{% trans "Save" %}
-
-
- {% trans "Delete event" %}
-
-
-
- {% trans "Delete personal data" %}
+
+ {% trans "Cancel or delete event" %}
diff --git a/src/pretix/control/templates/pretixcontrol/orders/cancel.html b/src/pretix/control/templates/pretixcontrol/orders/cancel.html
new file mode 100644
index 0000000000..2f7f44788d
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/orders/cancel.html
@@ -0,0 +1,52 @@
+{% extends "pretixcontrol/event/base.html" %}
+{% load i18n %}
+{% load eventsignal %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Cancel event" %}{% endblock %}
+{% block content %}
+ {% trans "Cancel event" %}
+
+ {% 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 %}
+
+ {% 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 %}
+
+
+ {% trans "All actions performed on this page are irreversible. If in doubt, please contact support before using it." %}
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 45d28809b8..f22c8348b3 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -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[^/]+)/$', shredder.ShredDownloadView.as_view(), name='event.shredder.download'),
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py
index 5a84edd92e..07ab55a077 100644
--- a/src/pretix/control/views/event.py
+++ b/src/pretix/control/views/event.py
@@ -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={
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py
index f720198307..bd864fffc3 100644
--- a/src/pretix/control/views/orders.py
+++ b/src/pretix/control/views/orders.py
@@ -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)
diff --git a/src/tests/base/test_cancelevent.py b/src/tests/base/test_cancelevent.py
new file mode 100644
index 0000000000..388609561d
--- /dev/null
+++ b/src/tests/base/test_cancelevent.py
@@ -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')
diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py
index a47e19cef7..5b11b4f169 100644
--- a/src/tests/base/test_models.py
+++ b/src/tests/base/test_models.py
@@ -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):
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index 23336e156b..655d46adaa 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -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),
diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py
index 3b1a92fb92..d36fb9a5bc 100644
--- a/src/tests/control/test_views.py
+++ b/src/tests/control/test_views.py
@@ -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),