mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
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:
@@ -267,6 +267,10 @@ def base_placeholders(sender, **kwargs):
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
|
||||
lambda event_or_subevent: event_or_subevent.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
),
|
||||
@@ -279,6 +283,11 @@ def base_placeholders(sender, **kwargs):
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'refund_amount', ['event_or_subevent', 'refund_amount'],
|
||||
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
|
||||
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
|
||||
event.currency),
|
||||
|
||||
@@ -448,16 +448,17 @@ class Order(LockModel, LoggedModel):
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
|
||||
190
src/pretix/base/services/cancelevent.py
Normal file
190
src/pretix/base/services/cancelevent.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, IntegerField, OuterRef, Subquery, Sum,
|
||||
)
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
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 (
|
||||
Event, InvoiceAddress, Order, OrderFee, OrderPosition, SubEvent, User,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||
refund_amount: Decimal, user: User, positions: list):
|
||||
with language(order.locale):
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress()
|
||||
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
||||
order=order, position_or_address=ia, event=order.event)
|
||||
try:
|
||||
order.send_mail(
|
||||
subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
user,
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent')
|
||||
|
||||
for p in positions:
|
||||
if subevent and p.subevent_id != subevent.id:
|
||||
continue
|
||||
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event,
|
||||
event=order.event,
|
||||
refund_amount=refund_amount,
|
||||
position_or_address=p,
|
||||
order=order, position=p)
|
||||
try:
|
||||
order.send_mail(
|
||||
subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
position=p,
|
||||
user=user
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent to attendee')
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
|
||||
keep_fee_percentage: str, keep_fees: bool, send: bool, send_subject: dict,
|
||||
send_message: dict, user: int):
|
||||
send_subject = LazyI18nString(send_subject)
|
||||
send_message = LazyI18nString(send_message)
|
||||
if user:
|
||||
user = User.objects.get(pk=user)
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
|
||||
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
|
||||
pcnt__gt=0
|
||||
).all()
|
||||
|
||||
if subevent:
|
||||
subevent = event.subevents.get(pk=subevent)
|
||||
|
||||
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
|
||||
subevent=subevent
|
||||
)
|
||||
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
|
||||
subevent=subevent
|
||||
)
|
||||
orders_to_change = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
has_other_subevent=Exists(has_other_subevent),
|
||||
).filter(
|
||||
has_subevent=True, has_other_subevent=True
|
||||
)
|
||||
orders_to_cancel = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
has_other_subevent=Exists(has_other_subevent),
|
||||
).filter(
|
||||
has_subevent=True, has_other_subevent=False
|
||||
)
|
||||
|
||||
subevent.active = False
|
||||
subevent.save(update_fields=['active'])
|
||||
subevent.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
else:
|
||||
orders_to_change = event.orders.none()
|
||||
|
||||
for i in event.items.filter(active=True):
|
||||
i.active = False
|
||||
i.save(update_fields=['active'])
|
||||
i.log_action(
|
||||
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
failed = 0
|
||||
|
||||
for o in orders_to_cancel.only('id', 'total'):
|
||||
try:
|
||||
refund_amount = Decimal('0.00')
|
||||
|
||||
fee = Decimal('0.00')
|
||||
if keep_fees:
|
||||
fee += o.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee)
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee)
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk)
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
except LockTimeoutException:
|
||||
logger.exception("Could not cancel order")
|
||||
failed += 1
|
||||
except OrderError:
|
||||
logger.exception("Could not cancel order")
|
||||
failed += 1
|
||||
|
||||
for o in orders_to_change.values_list('id', flat=True):
|
||||
with transaction.atomic():
|
||||
o = event.orders.select_for_update().get(pk=o)
|
||||
refund_amount = Decimal('0.00')
|
||||
total = Decimal('0.00')
|
||||
positions = []
|
||||
|
||||
ocm = OrderChangeManager(o, user=user, notify=False)
|
||||
for p in o.positions.all():
|
||||
if p.subevent == subevent:
|
||||
total += p.price
|
||||
ocm.cancel(p)
|
||||
positions.append(p)
|
||||
|
||||
fee = Decimal('0.00')
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
if fee:
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=fee,
|
||||
order=o,
|
||||
tax_rule=o.event.settings.tax_rate_default,
|
||||
)
|
||||
f._calculate_tax()
|
||||
ocm.add_fee(f)
|
||||
|
||||
ocm.commit()
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk)
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
|
||||
return failed
|
||||
@@ -279,7 +279,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
"""
|
||||
with transaction.atomic():
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
order = Order.objects.select_for_update().get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
if isinstance(api_token, int):
|
||||
@@ -1075,6 +1075,7 @@ class OrderChangeManager:
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
|
||||
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
@@ -1188,6 +1189,11 @@ class OrderChangeManager:
|
||||
self._operations.append(self.CancelFeeOperation(fee))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_fee(self, fee: OrderFee):
|
||||
self._totaldiff += fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.AddFeeOperation(fee))
|
||||
|
||||
def change_fee(self, fee: OrderFee, value: Decimal):
|
||||
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
|
||||
self._totaldiff += value.gross - fee.value
|
||||
@@ -1448,6 +1454,13 @@ class OrderChangeManager:
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
elif isinstance(op, self.AddFeeOperation):
|
||||
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
})
|
||||
op.fee.order = self.order
|
||||
op.fee._calculate_tax()
|
||||
op.fee.save()
|
||||
elif isinstance(op, self.FeeValueOperation):
|
||||
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
@@ -1800,6 +1813,61 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
|
||||
raise OrderError(str(error_messages['busy']))
|
||||
|
||||
|
||||
def _try_auto_refund(order):
|
||||
notify_admin = False
|
||||
error = False
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
if refund_amount <= Decimal('0.00'):
|
||||
return
|
||||
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
elif refund_amount != Decimal('0.00'):
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_(
|
||||
'There was an error while trying to send the money back to you. Please contact the event organizer '
|
||||
'for further information.')
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
@scopes_disabled()
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
@@ -1809,52 +1877,7 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
notify_admin = False
|
||||
error = False
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
elif refund_amount != Decimal('0.00'):
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
|
||||
)
|
||||
_try_auto_refund(order)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user