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 %} +
+
+
+ {% csrf_token %} + + + +
+
+
+
+ +
+
+

{% 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 @@
+
+ + + {% trans "Cancel or delete event" %} + +
{% 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." %} + +
+
+ {% csrf_token %} + {% bootstrap_form_errors form %} + {% if request.event.has_subevents %} +
+ {% trans "Select date" context "subevents" %} + {% bootstrap_field form.subevent layout="control" %} +
+ {% endif %} +
+ {% trans "Refund options" %} + {% 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" %} +
+
+ {% trans "Send out emails" %} + {% bootstrap_field form.send layout="control" %} + {% bootstrap_field form.send_subject layout="horizontal" %} + {% bootstrap_field form.send_message layout="horizontal" %} +
+
+ +
+
+{% 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),