Introduce cancellation requests (#1627)

* Allow to adjust the cancellation fee without JS

* Introduce cancellation requests

* ignore→delete

* Change a few things after Martin's review

* Add a few tests
This commit is contained in:
Raphael Michel
2020-03-25 14:13:55 +01:00
committed by GitHub
parent 173a23722a
commit 8a6334bd86
24 changed files with 352 additions and 33 deletions

View File

@@ -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',
]

View File

@@ -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(

View File

@@ -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.'),

View File

@@ -15,6 +15,7 @@
<fieldset>
<legend>{% trans "Paid orders" %}</legend>
{% 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" %}

View File

@@ -34,6 +34,15 @@
class="btn btn-primary">{% trans "Show pending refunds" %}</a>
</div>
{% endif %}
{% if has_cancellation_requests %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
This event contains <strong>requested cancellations</strong> that you should take care of.
{% endblocktrans %}
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=rc"
class="btn btn-primary">{% trans "Show orders requesting cancellation" %}</a>
</div>
{% endif %}
{% if has_pending_approvals %}
<div class="alert alert-warning">
{% blocktrans trimmed %}

View File

@@ -0,0 +1,32 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block title %}
{% trans "Ignore cancellation request" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Ignore cancellation request" %}
</h1>
<p>{% 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 %}</p>
<form method="post" href="">
{% csrf_token %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "No, take me back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Yes, delete request" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -112,6 +112,40 @@
<div class="row">
<div class="col-xs-12 col-lg-10">
{% for cr in order.cancellation_requests.all %}
<div class="panel panel-warning items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Cancellation request" %}
</h3>
</div>
<div class="panel-body">
{% trans "The customer asked you to cancel the order with the following settings:" %}
<dl class="dl-horizontal">
<dt>{% trans "Refund method" %}</dt>
<dd>
{% if cr.refund_as_giftcard %}
{% trans "Gift card" %}
{% else %}
{% trans "Original payment method" %}
{% endif %}
</dd>
<dt>{% trans "Cancellation fee" %}</dt>
<dd>{{ cr.cancellation_fee|money:request.event.currency }}</dd>
</dl>
<div class="text-right">
<a href="{% url "control:event.order.cancellationrequests.delete" event=request.event.slug organizer=request.event.organizer.slug code=order.code req=cr.pk %}"
class="btn btn-default btn-lg" data-toggle="tooltip">
{% trans "Delete request" %}
</a>
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c&req={{ cr.pk }}" class="btn btn-primary btn-lg">
{% trans "Cancel order" %}
</a>
</div>
</div>
</div>
{% endfor %}
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">

View File

@@ -94,7 +94,7 @@
<td>
<div class="input-group">
<input type="text" name="refund-new-giftcard"
title="" class="form-control" value="{{ 0|floatformat:2 }}">
title="" class="form-control" value="{{ giftcard_proposal|floatformat:2 }}">
<span class="input-group-addon">
{{ request.event.currency }}
</span>

View File

@@ -128,6 +128,9 @@
</td>
<td>{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td class="text-right flip">
{% if o.has_cancellation_request %}
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
{% endif %}
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}

View File

@@ -256,6 +256,9 @@ urlpatterns = [
name='event.order.refunds.process'),
url(r'^orders/(?P<code>[0-9A-Z]+)/refunds/(?P<refund>\d+)/done$', orders.OrderRefundDone.as_view(),
name='event.order.refunds.done'),
url(r'^orders/(?P<code>[0-9A-Z]+)/cancellationrequests/(?P<req>\d+)/delete$',
orders.OrderCancellationRequestDelete.as_view(),
name='event.order.cancellationrequests.delete'),
url(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
url(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
name='event.invoice.download'),

View File

@@ -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 = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
@@ -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)

View File

@@ -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.'))