Allow to cancel all orders in an event (#1596)

* Allow to cancel all orders in an event

* Add tests

* Actually add tests
This commit is contained in:
Raphael Michel
2020-03-03 16:55:05 +01:00
committed by GitHub
parent 07318be4c9
commit 62a86c9b4a
17 changed files with 929 additions and 66 deletions

View File

@@ -7,7 +7,11 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django.utils.translation import (
gettext_noop, pgettext_lazy, ugettext_lazy as _,
)
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator
@@ -514,3 +518,99 @@ class OrderRefundForm(forms.Form):
if data.get('mode') == 'partial' and not data.get('partial_amount'):
raise ValidationError(_('You need to specify an amount for a partial refund.'))
return data
class EventCancelForm(forms.Form):
subevent = forms.ModelChoiceField(
SubEvent.objects.none(),
label=pgettext_lazy('subevent', 'Date'),
required=True,
empty_label=None
)
auto_refund = forms.BooleanField(
label=_('Automatically refund money if possible'),
initial=True,
required=False
)
keep_fee_fixed = forms.DecimalField(
label=_("Keep a fixed cancellation fee"),
max_digits=10, decimal_places=2,
required=False
)
keep_fee_percentage = forms.DecimalField(
label=_("Keep a percentual cancellation fee"),
max_digits=10, decimal_places=2,
required=False
)
keep_fees = forms.BooleanField(
label=_("Keep payment, shipping and service fees"),
required=False,
)
send = forms.BooleanField(
label=_("Send information via email"),
required=False
)
send_subject = forms.CharField()
send_message = forms.CharField()
def _set_field_placeholders(self, fn, base_parameters):
phs = [
'{%s}' % p
for p in sorted(get_available_placeholders(self.event, base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(phs)
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['send_subject'] = I18nFormField(
label=_("Subject"),
required=True,
initial=_('Canceled: {event}'),
widget=I18nTextInput,
locales=self.event.settings.get('locales'),
)
self.fields['send_message'] = I18nFormField(
label=_('Message'),
widget=I18nTextarea,
required=True,
locales=self.event.settings.get('locales'),
initial=LazyI18nString.from_gettext(gettext_noop(
'Hello,\n\n'
'with this email, we regret to inform you that {event} has been canceled.\n\n'
'We will refund you {refund_amount} to your original payment method.\n\n'
'You can view the current state of your order here:\n\n{url}\n\nBest regards,\n\n'
'Your {event} team'
))
)
self._set_field_placeholders('send_subject', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'])
self._set_field_placeholders('send_message', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'])
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'Date')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
self.fields['subevent'].required = True
else:
del self.fields['subevent']
change_decimal_field(self.fields['keep_fee_fixed'], self.event.currency)

View File

@@ -65,6 +65,8 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.addfee':
return text + ' ' + str(_('A fee has been added'))
elif logentry.action_type == 'pretix.event.order.changed.feevalue':
return text + ' ' + _('A fee was changed from {old_price} to {new_price}.').format(
old_price=money_filter(Decimal(data['old_price']), event.currency),
@@ -213,6 +215,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.email.expire_warning_sent': _('An email has been sent with a warning that the order is about '
'to expire.'),
'pretix.event.order.email.order_canceled': _('An email has been sent to notify the user that the order has been canceled.'),
'pretix.event.order.email.event_canceled': _('An email has been sent to notify the user that the event has '
'been canceled.'),
'pretix.event.order.email.order_changed': _('An email has been sent to notify the user that the order has been changed.'),
'pretix.event.order.email.order_free': _('An email has been sent to notify the user that the order has been received.'),
'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'),

View File

@@ -0,0 +1,95 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block content %}
<h1>{% trans "Cancel or delete event" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Go offline" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can take your event offline. Nobody except your team will be able to see or access it any more.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<form action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
<button type="submit" class="btn btn-primary btn-lg btn-block">
<span class="fa fa-power-off"></span>
{% trans "Go offline" %}
</button>
</form>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Cancel event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
If you need to call of your event you want to cancel and refund all tickets, you can do so through
this option.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-block btn-lg">
<span class="fa fa-ban"></span>
{% trans "Cancel event" %}
</a>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Delete personal data" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can remove personal data such as names and email addresses from your event and only retain the
finanical information such as the number and type of ticekts sold.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<a href="
{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg btn-block">
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
</a>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Delete event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can delete your event completely only as long as it does not contain any undeletable data, such as
orders not performed in test mode.
{% endblocktrans %}
</div>
<div class="col-sm-12 col-md-3">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-block btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
<span class="fa fa-trash"></span>
{% trans "Delete event" %}
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -112,4 +112,11 @@
<div class="clear"></div>
</div>
</div>
<div class="text-right">
<a href="{% url "control:event.dangerzone" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-lg">
<span class="fa fa-trash"></span>
{% trans "Cancel or delete event" %}
</a>
</div>
{% endblock %}

View File

@@ -209,15 +209,10 @@
{% trans "Save" %}
</button>
<div class="pull-left">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn {% if request.event.allow_delete %}{% endif %} btn-danger btn-lg">
<span class="fa fa-trash"></span>
{% trans "Delete event" %}
</a>
<a href="{% url "control:event.shredder.start" organizer=request.organizer.slug event=request.event.slug %}"
<a href="{% url "control:event.dangerzone" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg">
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
<span class="fa fa-trash"></span>
{% trans "Cancel or delete event" %}
</a>
<a href="{% url "control:events.add" %}?clone={{ request.event.pk }}"
class="btn btn-default btn-lg">

View File

@@ -0,0 +1,52 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventsignal %}
{% load bootstrap3 %}
{% block title %}{% trans "Cancel event" %}{% endblock %}
{% block content %}
<h1>{% trans "Cancel event" %}</h1>
<div class="alert alert-warning">
{% blocktrans trimmed %}
You can use this page to cancel and refund all orders at once in case you need to call of your event.
This will also disable all products so no new orders can be created. Make sure that you check afterwards
for any overpaid orders or pending refunds that you need to take care of manually.
{% endblocktrans %}
<br><br>
{% blocktrans trimmed %}
After starting this operation, depending on the size of your event, it might take a few minutes or longer
until all orders are processed.
{% endblocktrans %}
<br><br>
<strong>
{% trans "All actions performed on this page are irreversible. If in doubt, please contact support before using it." %}
</strong>
</div>
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-download data-asynctask-long>
{% csrf_token %}
{% bootstrap_form_errors form %}
{% if request.event.has_subevents %}
<fieldset>
<legend>{% trans "Select date" context "subevents" %}</legend>
{% bootstrap_field form.subevent layout="control" %}
</fieldset>
{% endif %}
<fieldset>
<legend>{% trans "Refund options" %}</legend>
{% bootstrap_field form.auto_refund layout="control" %}
{% bootstrap_field form.keep_fee_fixed layout="control" %}
{% bootstrap_field form.keep_fee_percentage layout="control" %}
{% bootstrap_field form.keep_fees layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Send out emails" %}</legend>
{% bootstrap_field form.send layout="control" %}
{% bootstrap_field form.send_subject layout="horizontal" %}
{% bootstrap_field form.send_message layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-danger btn-save">
{% trans "Cancel all orders" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -265,6 +265,8 @@ urlpatterns = [
url(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'),
url(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'),
url(r'^shredder/$', shredder.StartShredView.as_view(), name='event.shredder.start'),
url(r'^shredder/export$', shredder.ShredExportView.as_view(), name='event.shredder.export'),
url(r'^shredder/download/(?P<file>[^/]+)/$', shredder.ShredDownloadView.as_view(), name='event.shredder.download'),

View File

@@ -530,6 +530,11 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
return resp
class DangerZone(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/dangerzone.html'
class DisplaySettings(View):
def get(self, request, *wargs, **kwargs):
return redirect(reverse('control:event.settings', kwargs={

View File

@@ -46,6 +46,7 @@ from pretix.base.models.orders import (
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.payment import PaymentException
from pretix.base.services import tickets
from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.export import export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
@@ -71,10 +72,11 @@ from pretix.control.forms.filter import (
EventOrderFilterForm, OverviewFilterForm, RefundFilterForm,
)
from pretix.control.forms.orders import (
CancelForm, CommentForm, ConfirmPaymentForm, ExporterForm, ExtendForm,
MarkPaidForm, OrderContactForm, OrderFeeChangeForm, OrderLocaleForm,
OrderMailForm, OrderPositionAddForm, OrderPositionAddFormset,
OrderPositionChangeForm, OrderRefundForm, OtherOperationsForm,
CancelForm, CommentForm, ConfirmPaymentForm, EventCancelForm, ExporterForm,
ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeChangeForm,
OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
OrderPositionAddFormset, OrderPositionChangeForm, OrderRefundForm,
OtherOperationsForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import PaginationMixin
@@ -1883,3 +1885,63 @@ class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView):
def filter_form(self):
return RefundFilterForm(data=self.request.GET, event=self.request.event,
initial={'status': 'open'})
class EventCancel(EventPermissionRequiredMixin, AsyncAction, FormView):
template_name = 'pretixcontrol/orders/cancel.html'
permission = 'can_change_orders'
form_class = EventCancelForm
task = cancel_event
known_errortypes = ['OrderError']
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return FormView.get(self, request, *args, **kwargs)
def get_form_kwargs(self):
k = super().get_form_kwargs()
k['event'] = self.request.event
return k
def form_valid(self, form):
return self.do(
self.request.event.pk,
subevent=form.cleaned_data['subevent'].pk if form.cleaned_data.get('subevent') else None,
auto_refund=form.cleaned_data.get('auto_refund'),
keep_fee_fixed=form.cleaned_data.get('keep_fee_fixed'),
keep_fee_percentage=form.cleaned_data.get('keep_fee_percentage'),
keep_fees=form.cleaned_data.get('keep_fees'),
send=form.cleaned_data.get('send'),
send_subject=form.cleaned_data.get('send_subject').data,
send_message=form.cleaned_data.get('send_message').data,
user=self.request.user.pk,
)
def get_success_message(self, value):
if value == 0:
return _('All orders have been canceled.')
else:
return _('The orders have been canceled. An error occured with {count} orders, please '
'check all uncanceled orders.').format(count=value)
def get_success_url(self, value):
return reverse('control:event.cancel', kwargs={
'organizer': self.request.organizer.slug,
'event': self.request.event.slug,
})
def get_error_url(self):
return reverse('control:event.cancel', kwargs={
'organizer': self.request.organizer.slug,
'event': self.request.event.slug,
})
def get_error_message(self, exception):
if isinstance(exception, str):
return exception
return super().get_error_message(exception)
def form_invalid(self, form):
messages.error(self.request, _('Your input was not valid.'))
return super().form_invalid(form)