mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
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:
@@ -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):
|
||||
|
||||
24
src/pretix/base/migrations/0148_cancellationrequest.py
Normal file
24
src/pretix/base/migrations/0148_cancellationrequest.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user