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

@@ -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):

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 %}
<div class="alert alert-info">
{% blocktrans trimmed %}
We've received your request to cancel this order. Please stay patient while the event organizer
decides on the cancellation.
{% endblocktrans %}
</div>
{% endif %}
{% if refunds %}
<div class="alert alert-info">
{% for r in refunds %}
@@ -280,17 +288,31 @@
{% if order.status == "p" and order.total != 0 %}
{% if order.user_cancel_fee >= order.total %}
<p>
{% 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." %}
</p>
{% elif order.user_cancel_fee %}
<p>
{% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %}
You can cancel this order. In this case, a cancellation fee of <strong>{{ fee }}</strong>
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 <strong>{{ fee }}</strong> 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 <strong>{{ fee }}</strong>
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 @@
</p>
{% else %}
<p>
{% 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" %}

View File

@@ -6,20 +6,35 @@
{% block title %}{% trans "Cancel order" %}{% endblock %}
{% block content %}
<h2>
{% 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 %}
</h2>
<form method="post"
action="{% eventurl request.event "presale:event.order.cancel.do" secret=order.secret order=order.code %}"
data-asynctask
class="">
<p>
{% 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 %}
</p>
{% if request.event.settings.cancel_allow_user_paid_require_approval %}
<p>
{% 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 %}
</p>
{% else %}
<p>
{% 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 %}
</p>
{% endif %}
{% if request.event.settings.cancel_allow_user_paid_adjust_fees %}
<p>
@@ -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!" %}
</p>
<div class="cancel-fee-slider">
<div id="cancel-fee-keep"></div>
<div id="cancel-fee-keep">Enter how much we can keep:</div>
<input
id="cancel-fee-slider"
type="text"
name="cancel_fee"
class="col-md-6 col-sm-12"
value="{{ cancel_fee|stringformat:".2f" }}"
data-slider-min="{{ cancel_fee|stringformat:".2f" }}"
data-slider-value="{{ cancel_fee|stringformat:".2f" }}"
data-slider-step="0.01"
data-slider-max="{{ order.payment_refund_sum|stringformat:".2f" }}"
data-slider-tooltip="hide"/>
<div id="cancel-fee-refund">AS</div>
<div id="cancel-fee-refund"></div>
</div>
<div class="text-center" id="cancel-fee-custom-link">
<a id="cancel-fee-custom"><small>Enter custom amount</small></a>
@@ -128,7 +142,11 @@
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-danger btn-lg" type="submit">
{% trans "Yes, cancel order" %}
{% if request.event.settings.cancel_allow_user_paid_require_approval %}
{% trans "Yes, request cancellation" %}
{% else %}
{% trans "Yes, cancel order" %}
{% endif %}
</button>
</div>
<div class="clearfix"></div>

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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