diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index ac766fd640..3ca522ae89 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -613,6 +613,7 @@ class EventSettingsSerializer(serializers.Serializer): 'cancel_allow_user_paid_keep_percentage', 'cancel_allow_user_paid_adjust_fees', 'cancel_allow_user_paid_refund_as_giftcard', + 'cancel_allow_user_paid_require_approval', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/migrations/0148_cancellationrequest.py b/src/pretix/base/migrations/0148_cancellationrequest.py new file mode 100644 index 0000000000..44690003b2 --- /dev/null +++ b/src/pretix/base/migrations/0148_cancellationrequest.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.4 on 2020-03-25 10:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0147_user_session_token'), + ] + + operations = [ + migrations.CreateModel( + name='CancellationRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('cancellation_fee', models.DecimalField(decimal_places=2, max_digits=10)), + ('refund_as_giftcard', models.BooleanField(default=False)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cancellation_requests', to='pretixbase.Order')), + ], + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 9931cbecda..11924c4ce3 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -470,6 +470,8 @@ class Order(LockModel, LoggedModel): """ from .checkin import Checkin + if self.cancellation_requests.exists(): + return False positions = list( self.positions.all().annotate( has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) @@ -2208,6 +2210,13 @@ class CachedCombinedTicket(models.Model): created = models.DateTimeField(auto_now_add=True) +class CancellationRequest(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='cancellation_requests') + created = models.DateTimeField(auto_now_add=True) + cancellation_fee = models.DecimalField(max_digits=10, decimal_places=2) + refund_as_giftcard = models.BooleanField(default=False) + + @receiver(post_delete, sender=CachedTicket) def cachedticket_delete(sender, instance, **kwargs): if instance.file: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index d9114108f0..77cba45de4 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -404,6 +404,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, data={'cancellation_fee': cancellation_fee}) + order.cancellation_requests.all().delete() if send_mail: email_template = order.event.settings.mail_text_order_canceled diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 6a27202673..26449ff77f 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -917,6 +917,16 @@ DEFAULTS = { help_text=_("With this option enabled, your customers can choose to get a smaller refund to support you.") ) }, + 'cancel_allow_user_paid_require_approval': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Customers can only request a cancellation that needs to be approved by the event organizer " + "before the order is canceled and a refund is issued."), + ) + }, 'cancel_allow_user_paid_refund_as_giftcard': { 'default': 'off', 'type': str, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 5fdf5770c8..3c7981b739 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -567,6 +567,7 @@ class CancelSettingsForm(SettingsForm): 'cancel_allow_user_paid_keep_percentage', 'cancel_allow_user_paid_adjust_fees', 'cancel_allow_user_paid_refund_as_giftcard', + 'cancel_allow_user_paid_require_approval', ] diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 58c12f4938..3af060b3b2 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -220,6 +220,7 @@ class EventOrderFilterForm(OrderFilterForm): ('underpaid', _('Underpaid')), ('pendingpaid', _('Pending (but fully paid)')), ('testmode', _('Test mode')), + ('rc', _('Cancellation requested')), ), required=False, ) @@ -305,6 +306,10 @@ class EventOrderFilterForm(OrderFilterForm): Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0)) | Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0)) ) + elif fdata.get('status') == 'rc': + qs = qs.filter( + cancellation_requests__isnull=False + ) elif fdata.get('status') == 'pendingpaid': qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True) qs = qs.filter( diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 2ff4845363..bd923bb910 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -189,6 +189,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'), 'pretix.event.order.expired': _('The order has been marked as expired.'), 'pretix.event.order.paid': _('The order has been marked as paid.'), + 'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'), 'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.canceled': _('The order has been canceled.'), 'pretix.event.order.reactivated': _('The order has been reactivated.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html index 1550eafe7d..48be7ccc21 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -15,6 +15,7 @@
{% trans "Paid orders" %} {% bootstrap_field form.cancel_allow_user_paid layout="control" %} + {% bootstrap_field form.cancel_allow_user_paid_require_approval layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index efcc955abd..737c5103bb 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -34,6 +34,15 @@ class="btn btn-primary">{% trans "Show pending refunds" %} {% endif %} + {% if has_cancellation_requests %} +
+ {% blocktrans trimmed %} + This event contains requested cancellations that you should take care of. + {% endblocktrans %} + {% trans "Show orders requesting cancellation" %} +
+ {% endif %} {% if has_pending_approvals %}
{% blocktrans trimmed %} diff --git a/src/pretix/control/templates/pretixcontrol/order/cancellation_request_delete.html b/src/pretix/control/templates/pretixcontrol/order/cancellation_request_delete.html new file mode 100644 index 0000000000..d2dcdbe395 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/cancellation_request_delete.html @@ -0,0 +1,32 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% block title %} + {% trans "Ignore cancellation request" %} +{% endblock %} +{% block content %} +

+ {% trans "Ignore cancellation request" %} +

+

{% blocktrans trimmed %} + Do you really want to remove this cancellation request? The user will not be informed automatically, but you + will have the option to email them individually in the next step. + {% endblocktrans %}

+ +
+ {% csrf_token %} +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 9f5ff404dc..6c67458a0b 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -112,6 +112,40 @@
+ {% for cr in order.cancellation_requests.all %} +
+
+

+ {% trans "Cancellation request" %} +

+
+
+ {% trans "The customer asked you to cancel the order with the following settings:" %} +
+
{% trans "Refund method" %}
+
+ {% if cr.refund_as_giftcard %} + {% trans "Gift card" %} + {% else %} + {% trans "Original payment method" %} + {% endif %} +
+
{% trans "Cancellation fee" %}
+
{{ cr.cancellation_fee|money:request.event.currency }}
+
+ +
+
+ {% endfor %} +

diff --git a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html index 9937cea3b4..8f0155ae60 100644 --- a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html +++ b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html @@ -94,7 +94,7 @@
+ title="" class="form-control" value="{{ giftcard_proposal|floatformat:2 }}"> {{ request.event.currency }} diff --git a/src/pretix/control/templates/pretixcontrol/orders/index.html b/src/pretix/control/templates/pretixcontrol/orders/index.html index d0a27b2c49..5631300e3c 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/index.html +++ b/src/pretix/control/templates/pretixcontrol/orders/index.html @@ -128,6 +128,9 @@ {{ o.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if o.has_cancellation_request %} + {% trans "CANCELLATION REQUESTED" %} + {% endif %} {% if o.has_external_refund or o.has_pending_refund %} {% trans "REFUND PENDING" %} {% elif o.has_pending_refund %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index c6743915cd..f1cefb7d0d 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -256,6 +256,9 @@ urlpatterns = [ name='event.order.refunds.process'), url(r'^orders/(?P[0-9A-Z]+)/refunds/(?P\d+)/done$', orders.OrderRefundDone.as_view(), name='event.order.refunds.done'), + url(r'^orders/(?P[0-9A-Z]+)/cancellationrequests/(?P\d+)/delete$', + orders.OrderCancellationRequestDelete.as_view(), + name='event.order.cancellationrequests.delete'), url(r'^orders/(?P[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'), url(r'^invoice/(?P[^/]+)$', orders.InvoiceDownload.as_view(), name='event.invoice.download'), diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 59d5a3642b..b6f604325b 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -32,6 +32,7 @@ from pretix.control.signals import ( ) from pretix.helpers.daterange import daterange +from ...base.models.orders import CancellationRequest from ..logdisplay import OVERVIEW_BANLIST NUM_WIDGET = '
{num}{text}
' @@ -344,6 +345,9 @@ def event_index(request, organizer, event): status=Order.STATUS_PENDING, require_approval=True ).exists() + ctx['has_cancellation_requests'] = CancellationRequest.objects.filter( + order__event=request.event + ).exists() for a in ctx['actions']: a.display = a.display(request) diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 0a24cd22fe..efd9f1f3eb 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -13,7 +13,8 @@ from django.contrib import messages from django.core.files import File from django.db import transaction from django.db.models import ( - Count, IntegerField, OuterRef, Prefetch, ProtectedError, Q, Subquery, Sum, + Count, Exists, IntegerField, OuterRef, Prefetch, ProtectedError, Q, + Subquery, Sum, ) from django.forms import formset_factory from django.http import ( @@ -33,6 +34,7 @@ from django.views.generic import ( from i18nfield.strings import LazyI18nString from pretix.base.channels import get_all_sales_channels +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 ( @@ -41,7 +43,7 @@ from pretix.base.models import ( generate_position_secret, generate_secret, ) from pretix.base.models.orders import ( - OrderFee, OrderPayment, OrderPosition, OrderRefund, + CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund, ) from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix from pretix.base.payment import PaymentException @@ -116,10 +118,11 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView): Order.annotate_overpayments(Order.objects).filter( pk__in=[o.pk for o in ctx['orders']] ).annotate( - pcnt=Subquery(s, output_field=IntegerField()) + pcnt=Subquery(s, output_field=IntegerField()), + has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef('pk'))) ).values( 'pk', 'pcnt', 'is_overpaid', 'is_underpaid', 'is_pending_with_full_payment', 'has_external_refund', - 'has_pending_refund' + 'has_pending_refund', 'has_cancellation_request' ) } @@ -132,6 +135,7 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView): o.is_pending_with_full_payment = annotated.get(o.pk)['is_pending_with_full_payment'] o.has_external_refund = annotated.get(o.pk)['has_external_refund'] o.has_pending_refund = annotated.get(o.pk)['has_pending_refund'] + o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request'] if ctx['page_obj'].paginator.count < 1000: # Performance safeguard: Only count positions if the data set is small @@ -607,6 +611,40 @@ class OrderRefundDone(OrderView): }) +class OrderCancellationRequestDelete(OrderView): + permission = 'can_change_orders' + + @cached_property + def req(self): + return get_object_or_404(self.order.cancellation_requests, pk=self.kwargs['req']) + + def post(self, *args, **kwargs): + with transaction.atomic(): + self.req.delete() + self.order.log_action('pretix.event.order.cancellationrequest.deleted', { + }, user=self.request.user) + + messages.success(self.request, _('The request has been removed. If you want, you can now inform the user.')) + with language(self.order.locale): + return redirect(reverse('control:event.order.sendmail', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'code': self.order.code + }) + '?' + urlencode({ + 'subject': _('Your cancellation request'), + 'message': _('Hello,\n\nunfortunately, we were unable to accommodate your request and cancel your ' + 'order.\n\n' + 'Your {event} team').format( + event="{event}", + ) + })) + + def get(self, *args, **kwargs): + return render(self.request, 'pretixcontrol/order/cancellation_request_delete.html', { + 'order': self.order, + }) + + class OrderPaymentConfirm(OrderView): permission = 'can_change_orders' @@ -679,7 +717,14 @@ class OrderRefundView(OrderView): full_refund = self.order.payment_refund_sum else: full_refund = self.start_form.cleaned_data.get('partial_amount') - proposals = self.order.propose_auto_refunds(full_refund, payments=payments) + if self.request.GET.get('giftcard', 'false') == 'true': + proposals = { + None: full_refund + } + giftcard_proposal = full_refund + else: + proposals = self.order.propose_auto_refunds(full_refund, payments=payments) + giftcard_proposal = Decimal('0.00') to_refund = full_refund - sum(proposals.values()) for p in payments: p.propose_refund = proposals.get(p, 0) @@ -873,6 +918,7 @@ class OrderRefundView(OrderView): 'payments': payments, 'remainder': to_refund, 'order': self.order, + 'giftcard_proposal': giftcard_proposal, 'partial_amount': ( self.request.POST.get('start-partial_amount') if self.request.method == 'POST' else self.request.GET.get('start-partial_amount') @@ -897,6 +943,12 @@ class OrderRefundView(OrderView): class OrderTransition(OrderView): permission = 'can_change_orders' + @cached_property + def req(self): + if 'req' not in self.request.GET: + return None + return get_object_or_404(self.order.cancellation_requests, pk=self.request.GET.get('req')) + @cached_property def mark_paid_form(self): return MarkPaidForm( @@ -909,6 +961,9 @@ class OrderTransition(OrderView): return CancelForm( instance=self.order, data=self.request.POST if self.request.method == "POST" else None, + initial={ + 'cancellation_fee': self.req.cancellation_fee if self.req else Decimal('0.00') + } ) def post(self, *args, **kwargs): @@ -997,8 +1052,9 @@ class OrderTransition(OrderView): 'event': self.request.event.slug, 'organizer': self.request.event.organizer.slug, 'code': self.order.code - }) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format( - self.order.pending_sum * -1 + }) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}&giftcard={}'.format( + round_decimal(self.order.pending_sum * -1), + 'true' if self.req and self.req.refund_as_giftcard else 'false' )) messages.success(self.request, _('The order has been canceled.')) diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index b1530bad25..a83390d3ed 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -94,6 +94,14 @@ {% endif %} {% eventsignal event "pretix.presale.signals.order_info_top" order=order request=request %} {% if order.status == "p" or order.status == "c" %} + {% if order.cancellation_requests.exists %} +
+ {% blocktrans trimmed %} + We've received your request to cancel this order. Please stay patient while the event organizer + decides on the cancellation. + {% endblocktrans %} +
+ {% endif %} {% if refunds %}
{% for r in refunds %} @@ -280,17 +288,31 @@ {% if order.status == "p" and order.total != 0 %} {% if order.user_cancel_fee >= order.total %}

- {% blocktrans trimmed %} - You can cancel this order, but you will not receive a refund. - {% endblocktrans %} + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed %} + You can request to cancel this order, but you will not receive a refund. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + You can cancel this order, but you will not receive a refund. + {% endblocktrans %} + {% endif %} {% trans "This will invalidate all tickets in this order." %}

{% elif order.user_cancel_fee %}

- {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} - You can cancel this order. In this case, a cancellation fee of {{ fee }} - will be kept and you will receive a refund of the remainder. - {% endblocktrans %} + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} + You can request to cancel this order. If your request is approved, a cancellation + fee of {{ fee }} will be kept and you will receive a refund of + the remainder. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} + You can cancel this order. In this case, a cancellation fee of {{ fee }} + will be kept and you will receive a refund of the remainder. + {% endblocktrans %} + {% endif %} {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} @@ -302,9 +324,16 @@

{% else %}

- {% blocktrans trimmed %} - You can cancel this order and receive a full refund. - {% endblocktrans %} + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed %} + You can request to cancel this order. If your request is approved, you get a full + refund. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + You can cancel this order and receive a full refund. + {% endblocktrans %} + {% endif %} {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_cancel.html b/src/pretix/presale/templates/pretixpresale/event/order_cancel.html index e9fd9f7c60..1d62afdaf3 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_cancel.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_cancel.html @@ -6,20 +6,35 @@ {% block title %}{% trans "Cancel order" %}{% endblock %} {% block content %}

- {% blocktrans trimmed with code=order.code %} - Cancel order: {{ code }} - {% endblocktrans %} + {% if request.event.settings.cancel_allow_user_paid_require_approval %} + {% blocktrans trimmed with code=order.code %} + Request cancellation: {{ code }} + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with code=order.code %} + Cancel order: {{ code }} + {% endblocktrans %} + {% endif %}

-

- {% blocktrans trimmed %} - If you cancel this order, all tickets will be invalidated and you can no longer use them. You cannot - revert this action. - {% endblocktrans %} -

+ {% if request.event.settings.cancel_allow_user_paid_require_approval %} +

+ {% blocktrans trimmed %} + You can request the cancellation of your order on this page. The event organizer will then decide + on your request. If they approve, your order will be canceled and all tickets will be invalidated. + {% endblocktrans %} +

+ {% else %} +

+ {% blocktrans trimmed %} + If you cancel this order, all tickets will be invalidated and you can no longer use them. You cannot + revert this action. + {% endblocktrans %} +

+ {% endif %} {% if request.event.settings.cancel_allow_user_paid_adjust_fees %}

@@ -36,19 +51,18 @@ {% trans "However, if you want us to help keep the lights on here, please consider using the slider below to request a smaller refund. Thank you!" %}

-
+
Enter how much we can keep:
-
AS
+
diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index d0caf0cb89..4a0b1dc4ac 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -765,7 +765,15 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): self.request.POST.get('giftcard') == 'true' ) ) - return self.do(self.order.pk, cancellation_fee=fee, try_auto_refund=True, refund_as_giftcard=giftcard) + if self.request.event.settings.cancel_allow_user_paid_require_approval: + self.order.cancellation_requests.create( + cancellation_fee=fee, + refund_as_giftcard=giftcard, + ) + self.order.log_action('pretix.event.order.refund.requested') + return self.success(None) + else: + return self.do(self.order.pk, cancellation_fee=fee, try_auto_refund=True, refund_as_giftcard=giftcard) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) @@ -773,7 +781,10 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): return ctx def get_success_message(self, value): - return _('The order has been canceled.') + if self.request.event.settings.cancel_allow_user_paid_require_approval: + return _('The cancellation has been requested.') + else: + return _('The order has been canceled.') @method_decorator(xframe_options_exempt, 'dispatch') diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 0f276e799d..781211ac2a 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -2205,3 +2205,41 @@ def test_refund_list(client, env): response = client.get('/control/event/dummy/dummy/orders/refunds/?status=all&provider=banktransfer') assert 'R-1' in response.content.decode() assert 'R-2' not in response.content.decode() + + +@pytest.mark.django_db +def test_delete_cancellation_request(client, env): + with scopes_disabled(): + r = env[2].cancellation_requests.create( + cancellation_fee=Decimal('4.00'), + refund_as_giftcard=True + ) + client.login(email='dummy@dummy.dummy', password='dummy') + response = client.post('/control/event/dummy/dummy/orders/FOO/cancellationrequests/{}/delete'.format(r.pk), {}, + follow=True) + assert 'alert-success' in response.content.decode() + assert not env[2].cancellation_requests.exists() + + +@pytest.mark.django_db +def test_approve_cancellation_request(client, env): + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=o.total) + o.status = Order.STATUS_PAID + o.save() + r = env[2].cancellation_requests.create( + cancellation_fee=Decimal('4.00'), + refund_as_giftcard=True + ) + client.login(email='dummy@dummy.dummy', password='dummy') + response = client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c&req={}'.format(r.pk), {}) + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select('input[name=cancellation_fee]')[0]['value'] == '4.00' + response = client.post('/control/event/dummy/dummy/orders/FOO/transition?req={}'.format(r.pk), { + 'status': 'c', + 'cancellation_fee': '4.00' + }, follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select('input[name=refund-new-giftcard]')[0]['value'] == '10.00' + assert not env[2].cancellation_requests.exists() diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 2b8c357824..e83c32a650 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -110,6 +110,7 @@ event_urls = [ "orders/ABC/approve", "orders/ABC/deny", "orders/ABC/checkvatid", + "orders/ABC/cancellationrequests/1/delete", "orders/ABC/payments/1/cancel", "orders/ABC/payments/1/confirm", "orders/ABC/refund", diff --git a/src/tests/plugins/badges/test_pdf.py b/src/tests/plugins/badges/test_pdf.py index 176f0d566d..2973b17f59 100644 --- a/src/tests/plugins/badges/test_pdf.py +++ b/src/tests/plugins/badges/test_pdf.py @@ -69,6 +69,7 @@ def test_generate_pdf(env): pdf = PdfFileReader(BytesIO(buf)) assert pdf.numPages == 2 + @pytest.mark.django_db def test_generate_pdf_multi(env): event, order, shirt = env diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 31d64389f3..07a9df9304 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -399,6 +399,33 @@ class OrdersTest(BaseOrdersTest): self.order.refresh_from_db() assert self.order.status == Order.STATUS_CANCELED + def test_orders_cancel_paid_request(self): + self.order.status = Order.STATUS_PAID + self.order.save() + with scopes_disabled(): + self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED) + self.event.settings.cancel_allow_user_paid = True + self.event.settings.cancel_allow_user_paid_keep = Decimal('3.00') + self.event.settings.cancel_allow_user_paid_require_approval = True + response = self.client.get( + '/%s/%s/order/%s/%s/cancel' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/cancel/do' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + }, follow=True) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + assert self.order.total == Decimal('23.00') + with scopes_disabled(): + assert not self.order.refunds.exists() + r = self.order.cancellation_requests.get() + assert r.cancellation_fee == Decimal('3.00') + def test_orders_cancel_paid_fee_autorefund_gift_card_optional(self): self.order.status = Order.STATUS_PAID self.order.save()